feat(challenges): 新增挑战模块与详情页,优化标签栏布局
- 新增挑战列表页 `app/(tabs)/challenges.tsx`,展示热门挑战卡片 - 新增挑战详情页 `app/challenges/[id].tsx`,支持排行榜、分享与参与 - 在标签栏中新增“挑战”入口,替换原有“发现”与“AI”页 - 调整标签栏间距与圆角,适配新布局 - 新增挑战相关路由常量 `TAB_CHALLENGES` - 迁移 `coach.tsx` 与 `explore.tsx` 至根目录,保持结构清晰
This commit is contained in:
32
AGENTS.md
Normal file
32
AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `app/` holds Expo Router screens; tab flows live in `app/(tabs)/`, while modal or detail pages sit alongside feature folders.
|
||||||
|
- Shared UI and domain logic belong in `components/`, `services/`, and `utils/`; Redux state is organized per feature under `store/`.
|
||||||
|
- Native iOS code (HealthKit bridge, widgets, quick actions) resides in `ios/`; design and process docs are tracked in `docs/`.
|
||||||
|
- Assets, fonts, and icons live in `assets/`; keep new media optimized and referenced via `@/assets` aliases.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `npm run ios` / `npm run ios-device` – builds and runs the prebuilt iOS app in Simulator or on a connected device.
|
||||||
|
- `npm run reset-project` – clears caches and regenerates native artifacts; use after dependency or native module changes.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- TypeScript with React hooks is standard; use functional components and keep state in Redux slices if shared.
|
||||||
|
- Follow ESLint (`eslint-config-expo`) and default Prettier formatting (2 spaces, trailing commas, single quotes).
|
||||||
|
- Name components in `PascalCase`, hooks/utilities in `camelCase`, and screen files with kebab-case (e.g., `ai-posture-assessment.tsx`).
|
||||||
|
- Co-locate feature assets, styles, and tests to simplify maintenance.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- Automated tests are minimal; add Jest + React Native Testing Library specs under `__tests__/` or alongside modules when adding complex logic.
|
||||||
|
- For health and native bridges, include reproduction steps and Simulator logs in PR descriptions.
|
||||||
|
- Always run linting and verify critical flows on an iOS simulator (HealthKit requires a real device for full validation).
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Prefer Conventional Commit prefixes (`feat`, `fix`, `chore`, etc.) with optional scope: `feat(water): 支持自定义提醒`. Keep summaries under 80 characters.
|
||||||
|
- Group related changes; avoid bundling unrelated features and formatting in one commit.
|
||||||
|
- PRs should describe the problem, solution, test evidence (commands run, screenshots, or screen recordings), and note any iOS-specific setup.
|
||||||
|
- Link to Linear/Jira issues where relevant and request review from feature owners or the iOS platform team.
|
||||||
|
|
||||||
|
## iOS Integration Notes
|
||||||
|
- HealthKit, widgets, and quick actions depend on native modules: update `ios/` and re-run `npm run ios` after modifying Swift or entitlement files.
|
||||||
|
- Keep App Group IDs, bundle identifiers, and signing assets consistent with `app.json` and `ios/digitalpilates.xcodeproj`; coordinate credential changes with release engineering.
|
||||||
@@ -21,8 +21,8 @@ type TabConfig = {
|
|||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
|
||||||
goals: { icon: 'flag.fill', title: '习惯' },
|
goals: { icon: 'flag.fill', title: '习惯' },
|
||||||
|
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||||
personal: { icon: 'person.fill', title: '个人' },
|
personal: { icon: 'person.fill', title: '个人' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,9 +35,10 @@ export default function TabLayout() {
|
|||||||
// Helper function to determine if a tab is selected
|
// Helper function to determine if a tab is selected
|
||||||
const isTabSelected = (routeName: string): boolean => {
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
const routeMap: Record<string, string> = {
|
const routeMap: Record<string, string> = {
|
||||||
explore: ROUTES.TAB_EXPLORE,
|
|
||||||
goals: ROUTES.TAB_GOALS,
|
|
||||||
statistics: ROUTES.TAB_STATISTICS,
|
statistics: ROUTES.TAB_STATISTICS,
|
||||||
|
goals: ROUTES.TAB_GOALS,
|
||||||
|
challenges: ROUTES.TAB_CHALLENGES,
|
||||||
|
personal: ROUTES.TAB_PERSONAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||||
@@ -69,11 +70,11 @@ export default function TabLayout() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 2,
|
||||||
marginVertical: 10,
|
marginVertical: 10,
|
||||||
borderRadius: 25,
|
borderRadius: 25,
|
||||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||||
paddingHorizontal: isSelected ? 16 : 10,
|
paddingHorizontal: isSelected ? 8 : 4,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -91,7 +92,7 @@ export default function TabLayout() {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
}}
|
}}
|
||||||
numberOfLines={0 as any}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{tabConfig.title}
|
{tabConfig.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -148,12 +149,12 @@ export default function TabLayout() {
|
|||||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||||
shadowRadius: 10,
|
shadowRadius: 10,
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 6,
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 16,
|
||||||
left: 20,
|
left: 16,
|
||||||
right: 20,
|
right: 16,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
borderWidth: glassEffectAvailable ? 1 : 0,
|
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||||
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||||
@@ -177,7 +178,11 @@ export default function TabLayout() {
|
|||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="goals">
|
<NativeTabs.Trigger name="goals">
|
||||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
||||||
<Label>目标</Label>
|
<Label>习惯</Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="challenges">
|
||||||
|
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
|
||||||
|
<Label>挑战</Label>
|
||||||
</NativeTabs.Trigger>
|
</NativeTabs.Trigger>
|
||||||
<NativeTabs.Trigger name="personal">
|
<NativeTabs.Trigger name="personal">
|
||||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
||||||
@@ -193,9 +198,8 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||||
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
|
|
||||||
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
|
|
||||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||||
|
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
297
app/(tabs)/challenges.tsx
Normal file
297
app/(tabs)/challenges.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export const CHALLENGES = [
|
||||||
|
{
|
||||||
|
id: 'joyful-dog-run',
|
||||||
|
title: '遛狗跑步,欢乐一路',
|
||||||
|
dateRange: '9月01日 - 9月30日',
|
||||||
|
participantsLabel: '6,364 跑者',
|
||||||
|
image: 'https://images.unsplash.com/photo-1525253086316-d0c936c814f8?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
avatars: [
|
||||||
|
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544723795-3fbce826f51f?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1502823403499-6ccfcf4fb453?auto=format&fit=crop&w=200&q=80',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'penguin-swim',
|
||||||
|
title: '企鹅宝宝的游泳预备班',
|
||||||
|
dateRange: '9月01日 - 9月30日',
|
||||||
|
participantsLabel: '3,334 游泳者',
|
||||||
|
image: 'https://images.unsplash.com/photo-1531297484001-80022131f5a1?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
avatars: [
|
||||||
|
'https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1530268729831-4b0b9e170218?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=200&q=80',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hydration-hippo',
|
||||||
|
title: '学河马饮,做补水人',
|
||||||
|
dateRange: '9月01日 - 9月30日',
|
||||||
|
participantsLabel: '9,009 饮水者',
|
||||||
|
image: 'https://images.unsplash.com/photo-1481931098730-318b6f776db0?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
avatars: [
|
||||||
|
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544723660-4bfa6584218e?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544723795-3fbfb7c6a9f1?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=200&q=80',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'autumn-cycling',
|
||||||
|
title: '炎夏渐散,踏板骑秋',
|
||||||
|
dateRange: '9月01日 - 9月30日',
|
||||||
|
participantsLabel: '4,617 骑行者',
|
||||||
|
image: 'https://images.unsplash.com/photo-1509395176047-4a66953fd231?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
avatars: [
|
||||||
|
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'falcon-core',
|
||||||
|
title: '燃卡加练甄秋腰',
|
||||||
|
dateRange: '9月01日 - 9月30日',
|
||||||
|
participantsLabel: '11,995 健身爱好者',
|
||||||
|
image: 'https://images.unsplash.com/photo-1494871262121-6adf66e90adf?auto=format&fit=crop&w=1200&q=80',
|
||||||
|
avatars: [
|
||||||
|
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=200&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Challenge = (typeof CHALLENGES)[number];
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 36;
|
||||||
|
const CARD_IMAGE_WIDTH = 132;
|
||||||
|
const CARD_IMAGE_HEIGHT = 96;
|
||||||
|
|
||||||
|
export default function ChallengesScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const gradientColors =
|
||||||
|
theme === 'dark'
|
||||||
|
? ['#1f2230', '#10131e']
|
||||||
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
|
||||||
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
bounces
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.giftButton}
|
||||||
|
>
|
||||||
|
<IconSymbol name="gift.fill" size={22} color={colorTokens.onPrimary} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardsContainer}>
|
||||||
|
{CHALLENGES.map((challenge) => (
|
||||||
|
<ChallengeCard
|
||||||
|
key={challenge.id}
|
||||||
|
challenge={challenge}
|
||||||
|
surfaceColor={colorTokens.surface}
|
||||||
|
textColor={colorTokens.text}
|
||||||
|
mutedColor={colorTokens.textSecondary}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChallengeCardProps = {
|
||||||
|
challenge: Challenge;
|
||||||
|
surfaceColor: string;
|
||||||
|
textColor: string;
|
||||||
|
mutedColor: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.92}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: surfaceColor,
|
||||||
|
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: challenge.image }}
|
||||||
|
style={styles.cardImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<Text style={[styles.cardTitle, { color: textColor }]} numberOfLines={1}>
|
||||||
|
{challenge.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
|
||||||
|
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</Text>
|
||||||
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarStackProps = {
|
||||||
|
avatars: string[];
|
||||||
|
borderColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.avatarRow}>
|
||||||
|
{avatars.map((avatar, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${avatar}-${index}`}
|
||||||
|
source={{ uri: avatar }}
|
||||||
|
style={[
|
||||||
|
styles.avatar,
|
||||||
|
{ borderColor },
|
||||||
|
index === 0 ? null : styles.avatarOffset,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 26,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
giftShadow: {
|
||||||
|
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
borderRadius: 26,
|
||||||
|
},
|
||||||
|
giftButton: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cardsContainer: {
|
||||||
|
gap: 18,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowOffset: { width: 0, height: 16 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
cardImage: {
|
||||||
|
width: CARD_IMAGE_WIDTH,
|
||||||
|
height: CARD_IMAGE_HEIGHT,
|
||||||
|
borderRadius: 22,
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
cardDate: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
cardParticipants: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
avatarRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: AVATAR_SIZE,
|
||||||
|
height: AVATAR_SIZE,
|
||||||
|
borderRadius: AVATAR_SIZE / 2,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
avatarOffset: {
|
||||||
|
marginLeft: -12,
|
||||||
|
},
|
||||||
|
});
|
||||||
683
app/challenges/[id].tsx
Normal file
683
app/challenges/[id].tsx
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
import { CHALLENGES, type Challenge } from '@/app/(tabs)/challenges';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Share,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const HERO_HEIGHT = width * 0.86;
|
||||||
|
const BADGE_SIZE = 120;
|
||||||
|
|
||||||
|
type ChallengeDetail = {
|
||||||
|
badgeImage: string;
|
||||||
|
periodLabel: string;
|
||||||
|
durationLabel: string;
|
||||||
|
requirementLabel: string;
|
||||||
|
summary?: string;
|
||||||
|
participantsCount: number;
|
||||||
|
rankingDescription?: string;
|
||||||
|
rankings: Record<string, RankingItem[]>;
|
||||||
|
highlightTitle: string;
|
||||||
|
highlightSubtitle: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RankingItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
metric: string;
|
||||||
|
badge?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
|
||||||
|
'hydration-hippo': {
|
||||||
|
badgeImage:
|
||||||
|
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
|
||||||
|
periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
|
||||||
|
durationLabel: '30 天',
|
||||||
|
requirementLabel: '喝水 1500ml 15 天以上',
|
||||||
|
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
|
||||||
|
participantsCount: 9009,
|
||||||
|
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
|
||||||
|
rankings: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: 'all-1',
|
||||||
|
name: '湖光暮色',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 3,200 ml',
|
||||||
|
badge: '金冠冠军',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'all-2',
|
||||||
|
name: '温柔潮汐',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 2,980 ml',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'all-3',
|
||||||
|
name: '晨雾河岸',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 2,860 ml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
male: [
|
||||||
|
{
|
||||||
|
id: 'male-1',
|
||||||
|
name: '北岸微风',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 3,120 ml',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'male-2',
|
||||||
|
name: '静水晚霞',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 2,940 ml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
female: [
|
||||||
|
{
|
||||||
|
id: 'female-1',
|
||||||
|
name: '露珠初晓',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 3,060 ml',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'female-2',
|
||||||
|
name: '桔梗水语',
|
||||||
|
avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80',
|
||||||
|
metric: '平均 2,880 ml',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
highlightTitle: '分享一次,免费参与',
|
||||||
|
highlightSubtitle: '解锁高级会员,无限加入挑战',
|
||||||
|
ctaLabel: '马上分享激励好友',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DETAIL: ChallengeDetail = {
|
||||||
|
badgeImage: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80',
|
||||||
|
periodLabel: '本周进行中',
|
||||||
|
durationLabel: '30 天',
|
||||||
|
requirementLabel: '保持专注完成每日任务',
|
||||||
|
participantsCount: 3200,
|
||||||
|
highlightTitle: '立即参加,点燃动力',
|
||||||
|
highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
|
||||||
|
ctaLabel: '立即加入挑战',
|
||||||
|
rankings: {
|
||||||
|
all: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEGMENTS = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'male', label: '男生' },
|
||||||
|
{ key: 'female', label: '女生' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SegmentKey = (typeof SEGMENTS)[number]['key'];
|
||||||
|
|
||||||
|
export default function ChallengeDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const challenge = useMemo<Challenge | undefined>(() => {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return CHALLENGES.find((item) => item.id === id);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const detail = useMemo<ChallengeDetail>(() => {
|
||||||
|
if (!id) return DEFAULT_DETAIL;
|
||||||
|
return DETAIL_PRESETS[id] ?? {
|
||||||
|
...DEFAULT_DETAIL,
|
||||||
|
periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel,
|
||||||
|
highlightTitle: `加入 ${challenge?.title ?? '挑战'}`,
|
||||||
|
};
|
||||||
|
}, [challenge?.dateRange, challenge?.title, id]);
|
||||||
|
|
||||||
|
const [segment, setSegment] = useState<SegmentKey>('all');
|
||||||
|
|
||||||
|
const rankingData = detail.rankings[segment] ?? detail.rankings.all ?? [];
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (!challenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
title: challenge.title,
|
||||||
|
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
|
||||||
|
url: challenge.image,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('分享失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
// 当前没有具体业务流程,先回退到挑战列表
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||||
|
<View style={styles.missingContainer}>
|
||||||
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战,稍后再试试吧。</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['bottom']} >
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={[styles.headerOverlay, { paddingTop: insets.top }]}
|
||||||
|
>
|
||||||
|
<HeaderBar
|
||||||
|
title=""
|
||||||
|
tone="light"
|
||||||
|
transparent
|
||||||
|
withSafeTop={false}
|
||||||
|
right={
|
||||||
|
<TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
|
||||||
|
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
bounces
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={[styles.scrollContent]}
|
||||||
|
>
|
||||||
|
<View style={styles.heroContainer}>
|
||||||
|
<Image source={{ uri: challenge.image }} style={styles.heroImage} resizeMode="cover" />
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.badgeWrapper}>
|
||||||
|
<View style={styles.badgeShadow}>
|
||||||
|
<Image source={{ uri: detail.badgeImage }} style={styles.badgeImage} resizeMode="cover" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.headerTextBlock}>
|
||||||
|
<Text style={styles.periodLabel}>{detail.periodLabel}</Text>
|
||||||
|
<Text style={styles.title}>{challenge.title}</Text>
|
||||||
|
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailCard}>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<View style={styles.detailIconWrapper}>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailTextWrapper}>
|
||||||
|
<Text style={styles.detailLabel}>{challenge.dateRange}</Text>
|
||||||
|
<Text style={styles.detailMeta}>{detail.durationLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<View style={styles.detailIconWrapper}>
|
||||||
|
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailTextWrapper}>
|
||||||
|
<Text style={styles.detailLabel}>{detail.requirementLabel}</Text>
|
||||||
|
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<View style={styles.detailIconWrapper}>
|
||||||
|
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.detailTextWrapper, { flex: 1 }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.detailLabel}>{detail.participantsCount.toLocaleString('zh-CN')} 人正在参与</Text>
|
||||||
|
<View style={styles.avatarRow}>
|
||||||
|
{challenge.avatars.slice(0, 6).map((avatar, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${avatar}-${index}`}
|
||||||
|
source={{ uri: avatar }}
|
||||||
|
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<TouchableOpacity style={styles.moreAvatarButton}>
|
||||||
|
<Text style={styles.moreAvatarText}>更多</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>排行榜</Text>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<Text style={styles.sectionAction}>查看全部</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{detail.rankingDescription ? (
|
||||||
|
<Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.segmentedControl}>
|
||||||
|
{SEGMENTS.map(({ key, label }) => {
|
||||||
|
const isActive = segment === key;
|
||||||
|
const disabled = !(detail.rankings[key] && detail.rankings[key].length);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={key}
|
||||||
|
style={[styles.segmentButton, isActive && styles.segmentButtonActive, disabled && styles.segmentDisabled]}
|
||||||
|
activeOpacity={disabled ? 1 : 0.8}
|
||||||
|
onPress={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
setSegment(key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive, disabled && styles.segmentLabelDisabled]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.rankingCard}>
|
||||||
|
{rankingData.length ? (
|
||||||
|
rankingData.map((item, index) => (
|
||||||
|
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
|
||||||
|
<View style={styles.rankingOrderCircle}>
|
||||||
|
<Text style={styles.rankingOrder}>{index + 1}</Text>
|
||||||
|
</View>
|
||||||
|
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
|
||||||
|
<View style={styles.rankingInfo}>
|
||||||
|
<Text style={styles.rankingName}>{item.name}</Text>
|
||||||
|
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
||||||
|
</View>
|
||||||
|
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRanking}>
|
||||||
|
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.highlightCard}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#5E8BFF', '#6B6CFF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
/>
|
||||||
|
<Text style={styles.highlightTitle}>{detail.highlightTitle}</Text>
|
||||||
|
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
|
||||||
|
<TouchableOpacity style={styles.highlightButton} activeOpacity={0.9} onPress={handleJoin}>
|
||||||
|
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f3f4fb',
|
||||||
|
},
|
||||||
|
headerOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
zIndex: 20,
|
||||||
|
},
|
||||||
|
heroContainer: {
|
||||||
|
height: HERO_HEIGHT,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderBottomLeftRadius: 36,
|
||||||
|
borderBottomRightRadius: 36,
|
||||||
|
},
|
||||||
|
heroImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: Platform.select({ ios: 40, default: 28 }),
|
||||||
|
},
|
||||||
|
badgeWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: -BADGE_SIZE / 2,
|
||||||
|
},
|
||||||
|
badgeShadow: {
|
||||||
|
width: BADGE_SIZE,
|
||||||
|
height: BADGE_SIZE,
|
||||||
|
borderRadius: BADGE_SIZE / 2,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 12,
|
||||||
|
shadowColor: 'rgba(17, 24, 39, 0.2)',
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 18,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
elevation: 12,
|
||||||
|
},
|
||||||
|
badgeImage: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: BADGE_SIZE / 2,
|
||||||
|
},
|
||||||
|
headerTextBlock: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginTop: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
periodLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#596095',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#1c1f3a',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: '#7080b4',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
detailCard: {
|
||||||
|
marginTop: 28,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
shadowColor: 'rgba(30, 41, 59, 0.18)',
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 20,
|
||||||
|
shadowOffset: { width: 0, height: 12 },
|
||||||
|
elevation: 8,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
detailIconWrapper: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: '#EFF1FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
detailTextWrapper: {
|
||||||
|
marginLeft: 14,
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1c1f3a',
|
||||||
|
},
|
||||||
|
detailMeta: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
},
|
||||||
|
avatarRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
avatarOffset: {
|
||||||
|
marginLeft: -12,
|
||||||
|
},
|
||||||
|
moreAvatarButton: {
|
||||||
|
marginLeft: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#EEF0FF',
|
||||||
|
},
|
||||||
|
moreAvatarText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#4F5BD5',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
marginTop: 36,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1c1f3a',
|
||||||
|
},
|
||||||
|
sectionAction: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#5F6BF0',
|
||||||
|
},
|
||||||
|
sectionSubtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
segmentedControl: {
|
||||||
|
marginTop: 20,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#EAECFB',
|
||||||
|
padding: 4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
segmentButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
segmentButtonActive: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
shadowColor: 'rgba(79, 91, 213, 0.25)',
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
segmentDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
segmentLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6372C6',
|
||||||
|
},
|
||||||
|
segmentLabelActive: {
|
||||||
|
color: '#4F5BD5',
|
||||||
|
},
|
||||||
|
segmentLabelDisabled: {
|
||||||
|
color: '#9AA3CF',
|
||||||
|
},
|
||||||
|
rankingCard: {
|
||||||
|
marginTop: 20,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
paddingVertical: 10,
|
||||||
|
shadowColor: 'rgba(30, 41, 59, 0.12)',
|
||||||
|
shadowOpacity: 0.16,
|
||||||
|
shadowRadius: 18,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
rankingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
},
|
||||||
|
rankingRowDivider: {
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: '#E5E7FF',
|
||||||
|
},
|
||||||
|
rankingOrderCircle: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#EEF0FF',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
rankingOrder: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#4F5BD5',
|
||||||
|
},
|
||||||
|
rankingAvatar: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
marginRight: 14,
|
||||||
|
},
|
||||||
|
rankingInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
rankingName: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1c1f3a',
|
||||||
|
},
|
||||||
|
rankingMetric: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
},
|
||||||
|
rankingBadge: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#A67CFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
emptyRanking: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyRankingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
},
|
||||||
|
highlightCard: {
|
||||||
|
marginTop: 32,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
borderRadius: 28,
|
||||||
|
paddingVertical: 28,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
highlightTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
highlightSubtitle: {
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
highlightButton: {
|
||||||
|
marginTop: 22,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.18)',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(247,248,255,0.5)',
|
||||||
|
},
|
||||||
|
highlightButtonLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
circularButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.24)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.45)',
|
||||||
|
},
|
||||||
|
shareIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
missingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
missingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -30,8 +30,8 @@ import { api, getAuthToken, postTextStream } from '@/services/api';
|
|||||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { HistoryModal } from '../../components/model/HistoryModal';
|
import { HistoryModal } from '../components/model/HistoryModal';
|
||||||
import { ActionSheet } from '../../components/ui/ActionSheet';
|
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||||
|
|
||||||
// 导入新的 coach 组件
|
// 导入新的 coach 组件
|
||||||
import {
|
import {
|
||||||
@@ -5,6 +5,7 @@ export const ROUTES = {
|
|||||||
TAB_COACH: '/coach',
|
TAB_COACH: '/coach',
|
||||||
TAB_GOALS: '/goals',
|
TAB_GOALS: '/goals',
|
||||||
TAB_STATISTICS: '/statistics',
|
TAB_STATISTICS: '/statistics',
|
||||||
|
TAB_CHALLENGES: '/challenges',
|
||||||
TAB_PERSONAL: '/personal',
|
TAB_PERSONAL: '/personal',
|
||||||
|
|
||||||
// 训练相关路由
|
// 训练相关路由
|
||||||
|
|||||||
Reference in New Issue
Block a user