feat: 集成 Redux 状态管理和用户目标管理功能

- 添加 Redux 状态管理,支持用户登录和个人信息的持久化
- 新增目标管理页面,允许用户设置每日卡路里和步数目标
- 更新首页,移除旧的活动展示,改为固定的热点功能卡片
- 修改布局以适应新功能的展示和交互
- 更新依赖,添加 @reduxjs/toolkit 和 react-redux 库以支持状态管理
- 新增 API 服务模块,处理与后端的交互
This commit is contained in:
2025-08-12 22:22:30 +08:00
parent c3d4630801
commit 00ddec25c5
14 changed files with 913 additions and 99 deletions

View File

@@ -2,25 +2,13 @@ import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { WorkoutCard } from '@/components/WorkoutCard';
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { getChineseGreeting } from '@/utils/date';
import { useRouter } from 'expo-router';
import React, { useEffect, useRef } from 'react';
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
const workoutData = [
{
id: 1,
title: 'AI体态评估',
duration: 5,
imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
},
{
id: 2,
title: '认证教练',
imageSource: require('@/assets/images/react-logo.png'),
}
];
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
export default function HomeScreen() {
const router = useRouter();
@@ -46,34 +34,33 @@ export default function HomeScreen() {
{/* Search Box */}
<SearchBox placeholder="搜索" />
{/* Popular Workouts Section */}
{/* Hot Features Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<ThemedText style={styles.sectionTitle}></ThemedText>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.workoutScrollContainer}
style={styles.workoutScroll}
>
{workoutData.map((workout) => (
<WorkoutCard
key={workout.id}
title={workout.title}
duration={workout.duration}
imageSource={workout.imageSource}
onPress={() => {
if (workout.title === 'AI体态评估') {
router.push('/ai-posture-assessment');
} else if (workout.title === '认证教练') {
router.push('/health-consultation' as any);
} else {
console.log(`Pressed ${workout.title}`);
}
}}
/>
))}
</ScrollView>
<View style={styles.featureGrid}>
<Pressable
style={[styles.featureCard, styles.featureCardPrimary]}
onPress={() => router.push('/ai-posture-assessment')}
>
<ThemedText style={styles.featureTitle}>AI体态评估</ThemedText>
<ThemedText style={styles.featureSubtitle}>3</ThemedText>
<View style={styles.featureCta}>
<ThemedText style={styles.featureCtaText}></ThemedText>
</View>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardSecondary]}
onPress={() => router.push('/health-consultation' as any)}
>
<ThemedText style={styles.featureTitle}>线</ThemedText>
<ThemedText style={styles.featureSubtitle}> · 11</ThemedText>
<View style={styles.featureCta}>
<ThemedText style={styles.featureCtaText}></ThemedText>
</View>
</Pressable>
</View>
</View>
{/* Today Plan Section */}
@@ -144,15 +131,62 @@ const styles = StyleSheet.create({
paddingHorizontal: 24,
marginBottom: 18,
},
featureGrid: {
paddingHorizontal: 24,
flexDirection: 'row',
justifyContent: 'space-between',
},
featureCard: {
width: '48%',
borderRadius: 16,
padding: 16,
backgroundColor: '#FFFFFF',
// iOS shadow
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
// Android shadow
elevation: 4,
},
featureCardPrimary: {
backgroundColor: '#EEF2FF', // 柔和的靛蓝背景
},
featureCardSecondary: {
backgroundColor: '#F0FDFA', // 柔和的青绿背景
},
featureIcon: {
fontSize: 28,
marginBottom: 8,
},
featureTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 6,
},
featureSubtitle: {
fontSize: 12,
color: '#6B7280',
lineHeight: 16,
marginBottom: 12,
},
featureCta: {
alignSelf: 'flex-start',
backgroundColor: '#0F172A',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
featureCtaText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
planList: {
paddingHorizontal: 24,
},
workoutScroll: {
paddingLeft: 24,
},
workoutScrollContainer: {
paddingRight: 24,
},
// 移除旧的滑动样式
bottomSpacing: {
height: 120,
},

View File

@@ -1,23 +1,15 @@
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import type { Href } from 'expo-router';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
} from 'react-native';
import { Alert, SafeAreaView, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function PersonalScreen() {
@@ -41,6 +33,7 @@ export default function PersonalScreen() {
avatarUri?: string | null;
};
const userProfileFromRedux = useAppSelector((s) => s.user.profile);
const [profile, setProfile] = useState<UserProfile>({});
const load = async () => {
@@ -73,6 +66,11 @@ export default function PersonalScreen() {
useEffect(() => { load(); }, []);
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
useEffect(() => {
if (userProfileFromRedux) {
setProfile(userProfileFromRedux);
}
}, [userProfileFromRedux]);
const formatHeight = () => {
if (profile.heightCm == null) return '--';
@@ -234,23 +232,11 @@ export default function PersonalScreen() {
const accountItems = [
{
icon: 'person-outline',
icon: 'flag-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '个人资料',
onPress: () => router.push('/profile/edit'),
},
{
icon: 'trophy-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '成就',
},
{
icon: 'time-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '活动历史',
title: '目标管理',
onPress: () => router.push('/profile/goals' as Href),
},
{
icon: 'stats-chart-outline',
@@ -265,7 +251,7 @@ export default function PersonalScreen() {
icon: 'notifications-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '弹窗通知',
title: '消息推送',
type: 'switch',
},
];

View File

@@ -4,7 +4,20 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { store } from '@/store';
import { rehydrateUser } from '@/store/userSlice';
import React from 'react';
import { Provider } from 'react-redux';
function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
React.useEffect(() => {
dispatch(rehydrateUser());
}, [dispatch]);
return <>{children}</>;
}
export default function RootLayout() {
const colorScheme = useColorScheme();
@@ -18,18 +31,23 @@ export default function RootLayout() {
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
<Provider store={store}>
<Bootstrapper>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</Bootstrapper>
</Provider>
);
}

View File

@@ -2,17 +2,21 @@ import { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { login } from '@/store/userSlice';
export default function LoginScreen() {
const router = useRouter();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
const dispatch = useAppDispatch();
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
@@ -40,11 +44,13 @@ export default function LoginScreen() {
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
// TODO: 将 credential 发送到后端换取应用会话。这里先直接返回上一页。
const identityToken = (credential as any)?.identityToken;
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
router.back();
} catch (err: any) {
if (err?.code === 'ERR_CANCELED') return;
Alert.alert('登录失败', '请稍后再试');
const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
} finally {
setLoading(false);
}
@@ -58,7 +64,7 @@ export default function LoginScreen() {
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: color.background }]}>
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: color.background }]}>
<ThemedView style={styles.container}>
{/* 自定义头部,与其它页面风格一致 */}
<View style={styles.header}>
@@ -71,7 +77,7 @@ export default function LoginScreen() {
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Digital Pilates</ThemedText>
<ThemedText style={[styles.title, { color: color.text }]}></ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText>
</View>

408
app/profile/goals.tsx Normal file
View File

@@ -0,0 +1,408 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ProgressBar } from '@/components/ProgressBar';
const STORAGE_KEYS = {
calories: '@goal_calories_burn',
steps: '@goal_daily_steps',
purposes: '@goal_pilates_purposes',
} as const;
const CALORIES_RANGE = { min: 100, max: 1500, step: 50 };
const STEPS_RANGE = { min: 2000, max: 20000, step: 500 };
export default function GoalsScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors = Colors[theme];
const [calories, setCalories] = useState<number>(400);
const [steps, setSteps] = useState<number>(8000);
const [purposes, setPurposes] = useState<string[]>([]);
useEffect(() => {
const load = async () => {
try {
const [c, s, p] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.calories),
AsyncStorage.getItem(STORAGE_KEYS.steps),
AsyncStorage.getItem(STORAGE_KEYS.purposes),
]);
if (c) {
const v = parseInt(c, 10);
if (!Number.isNaN(v)) setCalories(v);
}
if (s) {
const v = parseInt(s, 10);
if (!Number.isNaN(v)) setSteps(v);
}
if (p) {
try {
const parsed = JSON.parse(p);
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
} catch {}
}
} catch {}
};
load();
}, []);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => {});
}, [calories]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => {});
}, [steps]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => {});
}, [purposes]);
const caloriesPercent = useMemo(() =>
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
(CALORIES_RANGE.max - CALORIES_RANGE.min),
[calories]);
const stepsPercent = useMemo(() =>
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
(STEPS_RANGE.max - STEPS_RANGE.min),
[steps]);
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
setter(next);
};
const inc = (value: number, range: { step: number; max: number }) => {
return Math.min(range.max, value + range.step);
};
const dec = (value: number, range: { step: number; min: number }) => {
return Math.max(range.min, value - range.step);
};
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
= ({ title, subtitle, children }) => (
<View style={[styles.card, { backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
{children}
</View>
);
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
= ({ label, active, onPress }) => (
<TouchableOpacity
onPress={onPress}
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
>
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
</TouchableOpacity>
);
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
= ({ onDec, onInc }) => (
<View style={styles.stepperRow}>
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
</TouchableOpacity>
</View>
);
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
{ id: 'posture', label: '改善姿势体态', icon: 'body-outline' },
{ id: 'flexibility', label: '提高柔韧灵活', icon: 'walk-outline' },
{ id: 'balance', label: '强化平衡稳定', icon: 'accessibility-outline' },
{ id: 'shape', label: '塑形与线条', icon: 'heart-outline' },
{ id: 'stress', label: '减压与身心放松', icon: 'leaf-outline' },
{ id: 'backpain', label: '预防/改善腰背痛', icon: 'shield-checkmark-outline' },
{ id: 'rehab', label: '术后/伤后康复', icon: 'medkit-outline' },
{ id: 'performance', label: '提升运动表现', icon: 'fitness-outline' },
];
const togglePurpose = (id: string) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.selectionAsync();
}
setPurposes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
{/* Header参照 AI 体态测评页面的实现) */}
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => router.back()}
style={[styles.backButton, { backgroundColor: theme === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)' }]}
>
<Ionicons name="chevron-back" size={24} color={theme === 'dark' ? '#ECEDEE' : colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: theme === 'dark' ? '#ECEDEE' : colors.text }]}></Text>
<View style={{ width: 32 }} />
</View>
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
showsVerticalScrollIndicator={false}
>
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
<View style={styles.rowBetween}>
<Text style={[styles.valueText, { color: colors.text }]}>{calories} kcal</Text>
<Stepper
onDec={() => changeWithHaptics(dec(calories, CALORIES_RANGE), setCalories)}
onInc={() => changeWithHaptics(inc(calories, CALORIES_RANGE), setCalories)}
/>
</View>
<ProgressBar
progress={caloriesPercent}
height={18}
style={styles.progressMargin}
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
fillColor={colors.primary}
/>
<View style={styles.chipsRow}>
{[200, 300, 400, 500, 600].map((v) => (
<PresetChip key={v}
label={`${v}`}
active={v === calories}
onPress={() => changeWithHaptics(v, setCalories)}
/>
))}
</View>
<Text style={[styles.rangeHint, { color: colors.textMuted }]}> {CALORIES_RANGE.min}-{CALORIES_RANGE.max} kcal {CALORIES_RANGE.step}</Text>
</SectionCard>
<SectionCard title="每日步数目标" subtitle="快速设置你的目标步数">
<View style={styles.rowBetween}>
<Text style={[styles.valueText, { color: colors.text }]}>{steps.toLocaleString()} </Text>
<Stepper
onDec={() => changeWithHaptics(dec(steps, STEPS_RANGE), setSteps)}
onInc={() => changeWithHaptics(inc(steps, STEPS_RANGE), setSteps)}
/>
</View>
<ProgressBar
progress={stepsPercent}
height={18}
style={styles.progressMargin}
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
fillColor={colors.primary}
/>
<View style={styles.chipsRow}>
{[6000, 8000, 10000, 12000, 15000].map((v) => (
<PresetChip key={v}
label={`${v / 1000}k`}
active={v === steps}
onPress={() => changeWithHaptics(v, setSteps)}
/>
))}
</View>
<Text style={[styles.rangeHint, { color: colors.textMuted }]}> {STEPS_RANGE.min.toLocaleString()}-{STEPS_RANGE.max.toLocaleString()} {STEPS_RANGE.step}</Text>
</SectionCard>
<SectionCard
title="练习普拉提是为了什么"
subtitle="可多选"
>
<View style={styles.grid}>
{PURPOSE_OPTIONS.map((opt) => {
const active = purposes.includes(opt.id);
return (
<TouchableOpacity
key={opt.id}
style={[
styles.optionCard,
{
backgroundColor: active ? colors.primary : colors.card,
borderColor: active ? colors.primary : colors.border,
},
]}
activeOpacity={0.9}
onPress={() => togglePurpose(opt.id)}
>
<View style={styles.optionIconWrap}>
<Ionicons
name={opt.icon}
size={20}
color={active ? colors.onPrimary : colors.text}
/>
</View>
<Text
numberOfLines={2}
style={[
styles.optionLabel,
{ color: active ? colors.onPrimary : colors.text },
]}
>
{opt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{purposes.length > 0 && (
<Text style={[styles.selectedHint, { color: colors.textSecondary }]}> {purposes.length} </Text>
)}
</SectionCard>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 20,
fontWeight: '700',
},
safeArea: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingTop: 8,
},
card: {
borderRadius: 16,
padding: 16,
marginTop: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
cardSubtitle: {
fontSize: 13,
marginTop: 4,
},
rowBetween: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
valueText: {
fontSize: 24,
fontWeight: '800',
},
chipsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 14,
},
chip: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
},
chipText: {
fontSize: 14,
fontWeight: '600',
},
stepperRow: {
flexDirection: 'row',
gap: 10,
},
stepperBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
stepperText: {
fontSize: 20,
fontWeight: '700',
},
rangeHint: {
fontSize: 12,
marginTop: 10,
},
progressMargin: {
marginTop: 12,
},
grid: {
marginTop: 12,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
rowGap: 12,
},
optionCard: {
width: '48%',
borderRadius: 14,
paddingVertical: 14,
paddingHorizontal: 12,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
optionIconWrap: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.04)',
},
optionLabel: {
flex: 1,
fontSize: 14,
fontWeight: '700',
},
selectedHint: {
marginTop: 10,
fontSize: 12,
},
});