feat(i18n): 全面实现应用核心功能模块的国际化支持

- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
This commit is contained in:
richarjiang
2025-11-27 17:54:36 +08:00
parent 08adf0f20d
commit fbe0c92f0f
26 changed files with 2508 additions and 1622 deletions

View File

@@ -602,14 +602,14 @@ const styles = StyleSheet.create({
marginBottom: 26, marginBottom: 26,
}, },
title: { title: {
fontSize: 32, fontSize: 28,
fontWeight: '700', fontWeight: '700',
letterSpacing: 1, letterSpacing: 1,
fontFamily: 'AliBold' fontFamily: 'AliBold'
}, },
subtitle: { subtitle: {
marginTop: 6, marginTop: 6,
fontSize: 14, fontSize: 12,
fontWeight: '500', fontWeight: '500',
opacity: 0.8, opacity: 0.8,
fontFamily: 'AliRegular' fontFamily: 'AliRegular'
@@ -619,8 +619,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
joinButtonGlass: { joinButtonGlass: {
paddingHorizontal: 16, paddingHorizontal: 14,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 16, borderRadius: 16,
minWidth: 70, minWidth: 70,
alignItems: 'center', alignItems: 'center',
@@ -629,7 +629,7 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.45)', borderColor: 'rgba(255,255,255,0.45)',
}, },
joinButtonLabel: { joinButtonLabel: {
fontSize: 14, fontSize: 12,
fontWeight: '700', fontWeight: '700',
color: '#0f1528', color: '#0f1528',
letterSpacing: 0.5, letterSpacing: 0.5,
@@ -639,8 +639,8 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.7)', backgroundColor: 'rgba(255,255,255,0.7)',
}, },
createButton: { createButton: {
width: 40, width: 36,
height: 40, height: 36,
borderRadius: 20, borderRadius: 20,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@@ -13,6 +13,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { fetchMyProfile, login } from '@/store/userSlice'; import { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message'; import Toast from 'react-native-toast-message';
@@ -23,6 +24,7 @@ export default function LoginScreen() {
const color = Colors[scheme]; const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background; const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useI18n();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []); const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画 // 背景动效:轻微平移/旋转与呼吸动画
@@ -79,12 +81,12 @@ export default function LoginScreen() {
const guardAgreement = useCallback((action: () => void) => { const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) { if (!hasAgreed) {
Alert.alert( Alert.alert(
'请先阅读并同意', t('login.agreement.alert.title'),
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。', t('login.agreement.alert.message'),
[ [
{ text: '取消', style: 'cancel' }, { text: t('login.agreement.alert.cancel'), style: 'cancel' },
{ {
text: '同意并继续', text: t('login.agreement.alert.confirm'),
onPress: () => { onPress: () => {
setHasAgreed(true); setHasAgreed(true);
setTimeout(() => action(), 0); setTimeout(() => action(), 0);
@@ -96,7 +98,7 @@ export default function LoginScreen() {
return; return;
} }
action(); action();
}, [hasAgreed]); }, [hasAgreed, t]);
const onAppleLogin = useCallback(async () => { const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return; if (!appleAvailable) return;
@@ -110,7 +112,7 @@ export default function LoginScreen() {
}); });
const identityToken = (credential as any)?.identityToken; const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') { if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌'); throw new Error(t('login.errors.appleIdentityTokenMissing'));
} }
await dispatch(login({ appleIdentityToken: identityToken })).unwrap(); await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
@@ -118,7 +120,7 @@ export default function LoginScreen() {
await dispatch(fetchMyProfile()) await dispatch(fetchMyProfile())
Toast.show({ Toast.show({
text1: '登录成功', text1: t('login.success.loginSuccess'),
type: 'success', type: 'success',
}); });
// 登录成功后处理重定向 // 登录成功后处理重定向
@@ -145,12 +147,12 @@ export default function LoginScreen() {
console.log('err.code', err.code); console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return; if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试'; const message = err?.message || t('login.errors.loginFailed');
Alert.alert('登录失败', message); Alert.alert(t('login.errors.loginFailedTitle'), message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]); }, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo, dispatch, t]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用 // 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
@@ -244,14 +246,14 @@ export default function LoginScreen() {
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} /> <Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</TouchableOpacity> </TouchableOpacity>
)} )}
<Text style={[styles.headerTitle, { color: color.text }]}></Text> <Text style={[styles.headerTitle, { color: color.text }]}>{t('login.title')}</Text>
<View style={{ width: 32 }} /> <View style={{ width: 32 }} />
</View> </View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}> <View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText> <ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText> <ThemedText style={[styles.subtitle, { color: color.textMuted }]}>{t('login.subtitle')}</ThemedText>
</View> </View>
{/* Apple 登录 */} {/* Apple 登录 */}
@@ -276,12 +278,12 @@ export default function LoginScreen() {
color="#FFFFFF" color="#FFFFFF"
style={{ marginRight: 10 }} style={{ marginRight: 10 }}
/> />
<Text style={styles.appleText}>...</Text> <Text style={styles.appleText}>{t('login.loggingIn')}</Text>
</> </>
) : ( ) : (
<> <>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} /> <Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text> <Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</> </>
)} )}
</GlassView> </GlassView>
@@ -294,12 +296,12 @@ export default function LoginScreen() {
color="#FFFFFF" color="#FFFFFF"
style={{ marginRight: 10 }} style={{ marginRight: 10 }}
/> />
<Text style={styles.appleText}>...</Text> <Text style={styles.appleText}>{t('login.loggingIn')}</Text>
</> </>
) : ( ) : (
<> <>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} /> <Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text> <Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</> </>
)} )}
</View> </View>
@@ -319,13 +321,13 @@ export default function LoginScreen() {
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />} {hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View> </View>
</Pressable> </Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text> <Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.readAndAgree')}</Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}> <Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text> <Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
</Pressable> </Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text> <Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.and')}</Text>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}> <Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text> <Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
</Pressable> </Pressable>
</View> </View>

View File

@@ -2,9 +2,10 @@ import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { selectUserAge, selectUserProfile } from '@/store/userSlice'; import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getLocalizedDateFormat, getMonthDays, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health'; import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -24,6 +25,7 @@ type BasalMetabolismData = {
}; };
export default function BasalMetabolismDetailScreen() { export default function BasalMetabolismDetailScreen() {
const { t, i18n } = useI18n();
const userProfile = useAppSelector(selectUserProfile); const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge); const userAge = useAppSelector(selectUserAge);
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
@@ -140,9 +142,9 @@ export default function BasalMetabolismDetailScreen() {
// 获取当前选中日期 // 获取当前选中日期
const currentSelectedDate = useMemo(() => { const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh(); const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
return days[selectedIndex]?.date?.toDate() ?? new Date(); return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]); }, [selectedIndex, i18n.language]);
// 计算BMR范围 // 计算BMR范围
@@ -203,7 +205,7 @@ export default function BasalMetabolismDetailScreen() {
setSelectedIndex(index); setSelectedIndex(index);
// 获取选中日期 // 获取选中日期
const days = getMonthDaysZh(); const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
const selectedDate = days[index]?.date?.toDate(); const selectedDate = days[index]?.date?.toDate();
if (selectedDate) { if (selectedDate) {
@@ -247,7 +249,7 @@ export default function BasalMetabolismDetailScreen() {
} }
} catch (err) { } catch (err) {
if (!isCancelled) { if (!isCancelled) {
setError(err instanceof Error ? err.message : '获取数据失败'); setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
} }
} finally { } finally {
if (!isCancelled) { if (!isCancelled) {
@@ -280,7 +282,8 @@ export default function BasalMetabolismDetailScreen() {
// 显示周数 // 显示周数
const weekOfYear = dayjs(item.date).week(); const weekOfYear = dayjs(item.date).week();
const firstWeekOfYear = dayjs(item.date).startOf('year').week(); const firstWeekOfYear = dayjs(item.date).startOf('year').week();
return `${weekOfYear - firstWeekOfYear + 1}`; const weekNumber = weekOfYear - firstWeekOfYear + 1;
return t('basalMetabolismDetail.chart.weekLabel', { week: weekNumber });
default: default:
return dayjs(item.date).format('MM-DD'); return dayjs(item.date).format('MM-DD');
} }
@@ -319,7 +322,7 @@ export default function BasalMetabolismDetailScreen() {
{/* 头部导航 */} {/* 头部导航 */}
<HeaderBar <HeaderBar
title="基础代谢" title={t('basalMetabolismDetail.title')}
transparent transparent
right={ right={
<TouchableOpacity <TouchableOpacity
@@ -355,7 +358,9 @@ export default function BasalMetabolismDetailScreen() {
{/* 当前日期基础代谢显示 */} {/* 当前日期基础代谢显示 */}
<View style={styles.currentDataCard}> <View style={styles.currentDataCard}>
<Text style={styles.currentDataTitle}> <Text style={styles.currentDataTitle}>
{dayjs(currentSelectedDate).format('M月D日')} {t('basalMetabolismDetail.currentData.title', {
date: getLocalizedDateFormat(dayjs(currentSelectedDate), i18n.language as 'zh' | 'en')
})}
</Text> </Text>
<View style={styles.currentValueContainer}> <View style={styles.currentValueContainer}>
<Text style={styles.currentValue}> <Text style={styles.currentValue}>
@@ -366,21 +371,24 @@ export default function BasalMetabolismDetailScreen() {
if (selectedDateData?.value) { if (selectedDateData?.value) {
return Math.round(selectedDateData.value).toString(); return Math.round(selectedDateData.value).toString();
} }
return '--'; return t('basalMetabolismDetail.currentData.noData');
})()} })()}
</Text> </Text>
<Text style={styles.currentUnit}></Text> <Text style={styles.currentUnit}>{t('basalMetabolismDetail.currentData.unit')}</Text>
</View> </View>
{bmrRange && ( {bmrRange && (
<Text style={styles.rangeText}> <Text style={styles.rangeText}>
: {bmrRange.min}-{bmrRange.max} {t('basalMetabolismDetail.currentData.normalRange', {
min: bmrRange.min,
max: bmrRange.max
})}
</Text> </Text>
)} )}
</View> </View>
{/* 基础代谢统计 */} {/* 基础代谢统计 */}
<View style={styles.statsCard}> <View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text> <Text style={styles.statsTitle}>{t('basalMetabolismDetail.stats.title')}</Text>
{/* Tab 切换 */} {/* Tab 切换 */}
<View style={styles.tabContainer}> <View style={styles.tabContainer}>
@@ -390,7 +398,7 @@ export default function BasalMetabolismDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
{t('basalMetabolismDetail.stats.tabs.week')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -399,7 +407,7 @@ export default function BasalMetabolismDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
{t('basalMetabolismDetail.stats.tabs.month')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -408,28 +416,30 @@ export default function BasalMetabolismDetailScreen() {
{isLoading ? ( {isLoading ? (
<View style={styles.loadingChart}> <View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" /> <ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('basalMetabolismDetail.chart.loadingText')}</Text>
</View> </View>
) : error ? ( ) : error ? (
<View style={styles.errorChart}> <View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text> <Text style={styles.errorText}>
{t('basalMetabolismDetail.chart.error.text', { error })}
</Text>
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={() => { onPress={() => {
// 重新加载数据 // {t('basalMetabolismDetail.comments.reloadData')}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
fetchBasalMetabolismData(activeTab).then(data => { fetchBasalMetabolismData(activeTab).then(data => {
setChartData(data); setChartData(data);
setIsLoading(false); setIsLoading(false);
}).catch(err => { }).catch(err => {
setError(err instanceof Error ? err.message : '获取数据失败'); setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
setIsLoading(false); setIsLoading(false);
}); });
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.retryText}></Text> <Text style={styles.retryText}>{t('basalMetabolismDetail.chart.error.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? ( ) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
@@ -441,7 +451,7 @@ export default function BasalMetabolismDetailScreen() {
width={Dimensions.get('window').width - 80} width={Dimensions.get('window').width - 80}
height={220} height={220}
yAxisLabel="" yAxisLabel=""
yAxisSuffix="千卡" yAxisSuffix={t('basalMetabolismDetail.chart.yAxisSuffix')}
chartConfig={{ chartConfig={{
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff', backgroundGradientFrom: '#ffffff',
@@ -470,7 +480,7 @@ export default function BasalMetabolismDetailScreen() {
/> />
) : ( ) : (
<View style={styles.emptyChart}> <View style={styles.emptyChart}>
<Text style={styles.emptyChartText}></Text> <Text style={styles.emptyChartText}>{t('basalMetabolismDetail.chart.empty')}</Text>
</View> </View>
)} )}
</View> </View>
@@ -490,56 +500,66 @@ export default function BasalMetabolismDetailScreen() {
style={styles.closeButton} style={styles.closeButton}
onPress={() => setInfoModalVisible(false)} onPress={() => setInfoModalVisible(false)}
> >
<Text style={styles.closeButtonText}>×</Text> <Text style={styles.closeButtonText}>{t('basalMetabolismDetail.modal.closeButton')}</Text>
</TouchableOpacity> </TouchableOpacity>
{/* 标题 */} {/* 标题 */}
<Text style={styles.modalTitle}></Text> <Text style={styles.modalTitle}>{t('basalMetabolismDetail.modal.title')}</Text>
{/* 基础代谢定义 */} {/* 基础代谢定义 */}
<Text style={styles.modalDescription}> <Text style={styles.modalDescription}>
BMR {t('basalMetabolismDetail.modal.description')}
</Text> </Text>
{/* 为什么重要 */} {/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.importance.title')}</Text>
<Text style={styles.sectionContent}> <Text style={styles.sectionContent}>
60-75% {t('basalMetabolismDetail.modal.sections.importance.content')}
</Text> </Text>
{/* 正常范围 */} {/* 正常范围 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.normalRange.title')}</Text>
<Text style={styles.formulaText}> <Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5 - {t('basalMetabolismDetail.modal.sections.normalRange.formulas.male')}
</Text> </Text>
<Text style={styles.formulaText}> <Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161 - {t('basalMetabolismDetail.modal.sections.normalRange.formulas.female')}
</Text> </Text>
{bmrRange ? ( {bmrRange ? (
<> <>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text> <Text style={styles.rangeText}>
{t('basalMetabolismDetail.modal.sections.normalRange.userRange', {
min: bmrRange.min,
max: bmrRange.max
})}
</Text>
<Text style={styles.rangeNote}> <Text style={styles.rangeNote}>
(15%) {t('basalMetabolismDetail.modal.sections.normalRange.rangeNote')}
</Text> </Text>
<Text style={styles.userInfoText}> <Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg {t('basalMetabolismDetail.modal.sections.normalRange.userInfo', {
gender: t(`basalMetabolismDetail.gender.${userProfile.gender === 'male' ? 'male' : 'female'}`),
age: userAge,
height: userProfile.height,
weight: userProfile.weight
})}
</Text> </Text>
</> </>
) : ( ) : (
<Text style={styles.rangeText}></Text> <Text style={styles.rangeText}>
{t('basalMetabolismDetail.modal.sections.normalRange.incompleteInfo')}
</Text>
)} )}
{/* 提高代谢率的策略 */} {/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.strategies.title')}</Text>
<Text style={styles.strategyText}></Text> <Text style={styles.strategyText}>{t('basalMetabolismDetail.modal.sections.strategies.subtitle')}</Text>
<View style={styles.strategyList}> <View style={styles.strategyList}>
<Text style={styles.strategyItem}>1. (2-3)</Text> {(t('basalMetabolismDetail.modal.sections.strategies.items', { returnObjects: true }) as string[]).map((item: string, index: number) => (
<Text style={styles.strategyItem}>2. (HIIT)</Text> <Text key={index} style={styles.strategyItem}>{item}</Text>
<Text style={styles.strategyItem}>3. (1.6-2.2g)</Text> ))}
<Text style={styles.strategyItem}>4. (7-9/)</Text>
<Text style={styles.strategyItem}>5. (BMR的80%)</Text>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
fetchChallengeDetail, fetchChallengeDetail,
@@ -37,6 +38,7 @@ export default function ChallengeLeaderboardScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useI18n();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]); const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
@@ -75,12 +77,12 @@ export default function ChallengeLeaderboardScreen() {
if (!id) { if (!id) {
return ( return (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}> <View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={{ <View style={{
paddingTop: safeAreaTop paddingTop: safeAreaTop
}} /> }} />
<View style={styles.missingContainer}> <View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.notFound')}</Text>
</View> </View>
</View> </View>
); );
@@ -89,10 +91,10 @@ export default function ChallengeLeaderboardScreen() {
if (detailStatus === 'loading' && !challenge) { if (detailStatus === 'loading' && !challenge) {
return ( return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}> <View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} /> <ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
</View> </View>
</View> </View>
); );
@@ -131,10 +133,10 @@ export default function ChallengeLeaderboardScreen() {
if (!challenge) { if (!challenge) {
return ( return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}> <View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}> <View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}> <Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'} {detailError ?? t('challengeDetail.leaderboard.loadFailed')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -146,7 +148,7 @@ export default function ChallengeLeaderboardScreen() {
return ( return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}> <View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }} contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
@@ -178,7 +180,7 @@ export default function ChallengeLeaderboardScreen() {
{showInitialRankingLoading ? ( {showInitialRankingLoading ? (
<View style={styles.rankingLoading}> <View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} /> <ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
</View> </View>
) : rankingData.length ? ( ) : rankingData.length ? (
rankingData.map((item, index) => ( rankingData.map((item, index) => (
@@ -196,18 +198,18 @@ export default function ChallengeLeaderboardScreen() {
</View> </View>
) : ( ) : (
<View style={styles.emptyRanking}> <View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text> <Text style={styles.emptyRankingText}>{t('challengeDetail.leaderboard.empty')}</Text>
</View> </View>
)} )}
{isLoadingMore ? ( {isLoadingMore ? (
<View style={styles.loadMoreIndicator}> <View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" /> <ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text> <Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>{t('challengeDetail.leaderboard.loadMore')}</Text>
</View> </View>
) : null} ) : null}
{rankingLoadMoreStatus === 'failed' ? ( {rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}> <View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text> <Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</Text>
</View> </View>
) : null} ) : null}
</View> </View>

View File

@@ -25,6 +25,7 @@ const CIRCUMFERENCE_TYPES = [
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' }, { key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
]; ];
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { CircumferencePeriod } from '@/services/circumferenceAnalysis'; import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
@@ -35,6 +36,7 @@ export default function CircumferenceDetailScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const userProfile = useAppSelector(selectUserProfile); const userProfile = useAppSelector(selectUserProfile);
const { ensureLoggedIn } = useAuthGuard(); const { ensureLoggedIn } = useAuthGuard();
const { t } = useI18n();
// 日期相关状态 // 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -78,37 +80,37 @@ export default function CircumferenceDetailScreen() {
const measurements = [ const measurements = [
{ {
key: 'chestCircumference', key: 'chestCircumference',
label: '胸围', label: t('circumferenceDetail.measurements.chest'),
value: userProfile?.chestCircumference, value: userProfile?.chestCircumference,
color: '#FF6B6B', color: '#FF6B6B',
}, },
{ {
key: 'waistCircumference', key: 'waistCircumference',
label: '腰围', label: t('circumferenceDetail.measurements.waist'),
value: userProfile?.waistCircumference, value: userProfile?.waistCircumference,
color: '#4ECDC4', color: '#4ECDC4',
}, },
{ {
key: 'upperHipCircumference', key: 'upperHipCircumference',
label: '上臀围', label: t('circumferenceDetail.measurements.upperHip'),
value: userProfile?.upperHipCircumference, value: userProfile?.upperHipCircumference,
color: '#45B7D1', color: '#45B7D1',
}, },
{ {
key: 'armCircumference', key: 'armCircumference',
label: '臂围', label: t('circumferenceDetail.measurements.arm'),
value: userProfile?.armCircumference, value: userProfile?.armCircumference,
color: '#96CEB4', color: '#96CEB4',
}, },
{ {
key: 'thighCircumference', key: 'thighCircumference',
label: '大腿围', label: t('circumferenceDetail.measurements.thigh'),
value: userProfile?.thighCircumference, value: userProfile?.thighCircumference,
color: '#FFEAA7', color: '#FFEAA7',
}, },
{ {
key: 'calfCircumference', key: 'calfCircumference',
label: '小腿围', label: t('circumferenceDetail.measurements.calf'),
value: userProfile?.calfCircumference, value: userProfile?.calfCircumference,
color: '#DDA0DD', color: '#DDA0DD',
}, },
@@ -243,10 +245,10 @@ export default function CircumferenceDetailScreen() {
// 将YYYY-MM-DD格式转换为第几周 // 将YYYY-MM-DD格式转换为第几周
const weekOfYear = dayjs(item.label).week(); const weekOfYear = dayjs(item.label).week();
const firstWeekOfMonth = dayjs(item.label).startOf('month').week(); const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
return `${weekOfYear - firstWeekOfMonth + 1}`; return t('circumferenceDetail.chart.weekLabel', { week: weekOfYear - firstWeekOfMonth + 1 });
case 'year': case 'year':
// 将YYYY-MM格式转换为月份 // 将YYYY-MM格式转换为月份
return dayjs(item.label).format('M'); return t('circumferenceDetail.chart.monthLabel', { month: dayjs(item.label).format('M') });
default: default:
return item.label; return item.label;
} }
@@ -287,7 +289,7 @@ export default function CircumferenceDetailScreen() {
{/* 头部导航 */} {/* 头部导航 */}
<HeaderBar <HeaderBar
title="围度统计" title={t('circumferenceDetail.title')}
transparent transparent
/> />
@@ -338,7 +340,7 @@ export default function CircumferenceDetailScreen() {
{/* 围度统计 */} {/* 围度统计 */}
<View style={styles.statsCard}> <View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text> <Text style={styles.statsTitle}>{t('circumferenceDetail.title')}</Text>
{/* Tab 切换 */} {/* Tab 切换 */}
<View style={styles.tabContainer}> <View style={styles.tabContainer}>
@@ -348,7 +350,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.week')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -357,7 +359,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.month')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -366,7 +368,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.year')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -390,7 +392,7 @@ export default function CircumferenceDetailScreen() {
styles.legendText, styles.legendText,
!isVisible && styles.legendTextHidden !isVisible && styles.legendTextHidden
]}> ]}>
{type.label} {t(`circumferenceDetail.measurements.${type.key.replace('Circumference', '').toLowerCase()}`)}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -401,17 +403,17 @@ export default function CircumferenceDetailScreen() {
{isLoading ? ( {isLoading ? (
<View style={styles.loadingChart}> <View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" /> <ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('circumferenceDetail.loading')}</Text>
</View> </View>
) : error ? ( ) : error ? (
<View style={styles.errorChart}> <View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text> <Text style={styles.errorText}>{t('circumferenceDetail.error')}: {error}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))} onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.retryText}></Text> <Text style={styles.retryText}>{t('circumferenceDetail.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : processedChartData.datasets.length > 0 ? ( ) : processedChartData.datasets.length > 0 ? (
@@ -453,8 +455,8 @@ export default function CircumferenceDetailScreen() {
<View style={styles.emptyChart}> <View style={styles.emptyChart}>
<Text style={styles.emptyChartText}> <Text style={styles.emptyChartText}>
{processedChartData.datasets.length === 0 && !isLoading && !error {processedChartData.datasets.length === 0 && !isLoading && !error
? '暂无数据' ? t('circumferenceDetail.chart.empty')
: '请选择要显示的围度数据' : t('circumferenceDetail.chart.noSelection')
} }
</Text> </Text>
</View> </View>
@@ -469,12 +471,12 @@ export default function CircumferenceDetailScreen() {
setModalVisible(false); setModalVisible(false);
setSelectedMeasurement(null); setSelectedMeasurement(null);
}} }}
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'} title={selectedMeasurement ? t('circumferenceDetail.modal.title', { label: selectedMeasurement.label }) : t('circumferenceDetail.modal.defaultTitle')}
items={circumferenceOptions} items={circumferenceOptions}
selectedValue={selectedMeasurement?.currentValue} selectedValue={selectedMeasurement?.currentValue}
onValueChange={() => { }} // Real-time update not needed onValueChange={() => { }} // Real-time update not needed
onConfirm={handleUpdateMeasurement} onConfirm={handleUpdateMeasurement}
confirmButtonText="确认" confirmButtonText={t('circumferenceDetail.modal.confirm')}
pickerHeight={180} pickerHeight={180}
/> />
</View> </View>

View File

@@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
fetchActivityRingsForDate, fetchActivityRingsForDate,
@@ -51,6 +52,7 @@ type WeekData = {
}; };
export default function FitnessRingsDetailScreen() { export default function FitnessRingsDetailScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const [weekData, setWeekData] = useState<WeekData[]>([]); const [weekData, setWeekData] = useState<WeekData[]>([]);
@@ -82,7 +84,7 @@ export default function FitnessRingsDetailScreen() {
exerciseInfoAnim.setValue(0); exerciseInfoAnim.setValue(0);
} }
} catch (error) { } catch (error) {
console.error('加载锻炼分钟说明偏好失败:', error); console.error(t('fitnessRingsDetail.errors.loadExerciseInfoPreference'), error);
} }
}; };
@@ -98,7 +100,15 @@ export default function FitnessRingsDetailScreen() {
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const currentDay = startOfWeek.add(i, 'day'); const currentDay = startOfWeek.add(i, 'day');
const isToday = currentDay.isSame(today, 'day'); const isToday = currentDay.isSame(today, 'day');
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; const dayNames = [
t('fitnessRingsDetail.weekDays.monday'),
t('fitnessRingsDetail.weekDays.tuesday'),
t('fitnessRingsDetail.weekDays.wednesday'),
t('fitnessRingsDetail.weekDays.thursday'),
t('fitnessRingsDetail.weekDays.friday'),
t('fitnessRingsDetail.weekDays.saturday'),
t('fitnessRingsDetail.weekDays.sunday')
];
try { try {
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate()); const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
@@ -303,7 +313,7 @@ export default function FitnessRingsDetailScreen() {
setShowExerciseInfo(false); setShowExerciseInfo(false);
}); });
} catch (error) { } catch (error) {
console.error('保存锻炼分钟说明偏好失败:', error); console.error(t('fitnessRingsDetail.errors.saveExerciseInfoPreference'), error);
} }
}; };
@@ -380,7 +390,7 @@ export default function FitnessRingsDetailScreen() {
{/* 活动热量卡片 */} {/* 活动热量卡片 */}
<View style={styles.metricCard}> <View style={styles.metricCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.activeCalories.title')}</Text>
<TouchableOpacity style={styles.helpButton}> <TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text> <Text style={styles.helpIcon}>?</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -390,25 +400,25 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#FF3B30' }]}> <Text style={[styles.valueText, { color: '#FF3B30' }]}>
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal} {Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
</Text> </Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
</View> </View>
<Text style={styles.cardSubtext}> <Text style={styles.cardSubtext}>
{Math.round(activeEnergyBurned)} {Math.round(activeEnergyBurned)}{t('fitnessRingsDetail.cards.activeCalories.unit')}
</Text> </Text>
{renderBarChart( {renderBarChart(
hourlyCaloriesData.map(h => h.calories), hourlyCaloriesData.map(h => h.calories),
Math.max(activeEnergyBurnedGoal / 24, 1), Math.max(activeEnergyBurnedGoal / 24, 1),
'#FF3B30', '#FF3B30',
'千卡' t('fitnessRingsDetail.cards.activeCalories.unit')
)} )}
</View> </View>
{/* 锻炼分钟卡片 */} {/* 锻炼分钟卡片 */}
<View style={styles.metricCard}> <View style={styles.metricCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</Text>
<TouchableOpacity style={styles.helpButton}> <TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text> <Text style={styles.helpIcon}>?</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -418,18 +428,18 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#FF9500' }]}> <Text style={[styles.valueText, { color: '#FF9500' }]}>
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal} {Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
</Text> </Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
</View> </View>
<Text style={styles.cardSubtext}> <Text style={styles.cardSubtext}>
{Math.round(appleExerciseTime)} {Math.round(appleExerciseTime)}{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}
</Text> </Text>
{renderBarChart( {renderBarChart(
hourlyExerciseData.map(h => h.minutes), hourlyExerciseData.map(h => h.minutes),
Math.max(appleExerciseTimeGoal / 8, 1), Math.max(appleExerciseTimeGoal / 8, 1),
'#FF9500', '#FF9500',
'分钟' t('fitnessRingsDetail.cards.exerciseMinutes.unit')
)} )}
{/* 锻炼分钟说明 */} {/* 锻炼分钟说明 */}
@@ -450,15 +460,15 @@ export default function FitnessRingsDetailScreen() {
} }
]} ]}
> >
<Text style={styles.exerciseTitle}>:</Text> <Text style={styles.exerciseTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.title')}</Text>
<Text style={styles.exerciseDesc}> <Text style={styles.exerciseDesc}>
"快走" {t('fitnessRingsDetail.cards.exerciseMinutes.info.description')}
</Text> </Text>
<Text style={styles.exerciseRecommendation}> <Text style={styles.exerciseRecommendation}>
30 {t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
</Text> </Text>
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}> <TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
<Text style={styles.knowButtonText}></Text> <Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
)} )}
@@ -467,7 +477,7 @@ export default function FitnessRingsDetailScreen() {
{/* 活动小时数卡片 */} {/* 活动小时数卡片 */}
<View style={styles.metricCard}> <View style={styles.metricCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</Text>
<TouchableOpacity style={styles.helpButton}> <TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text> <Text style={styles.helpIcon}>?</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -477,18 +487,18 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#007AFF' }]}> <Text style={[styles.valueText, { color: '#007AFF' }]}>
{Math.round(appleStandHours)}/{appleStandHoursGoal} {Math.round(appleStandHours)}/{appleStandHoursGoal}
</Text> </Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
</View> </View>
<Text style={styles.cardSubtext}> <Text style={styles.cardSubtext}>
{Math.round(appleStandHours)} {Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
</Text> </Text>
{renderBarChart( {renderBarChart(
hourlyStandData.map(h => h.hasStood), hourlyStandData.map(h => h.hasStood),
1, 1,
'#007AFF', '#007AFF',
'小时' t('fitnessRingsDetail.cards.standHours.unit')
)} )}
</View> </View>
</View> </View>
@@ -536,9 +546,9 @@ export default function FitnessRingsDetailScreen() {
{/* 周闭环天数统计 */} {/* 周闭环天数统计 */}
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statRow}> <View style={styles.statRow}>
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}></Text> <Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>{t('fitnessRingsDetail.stats.weeklyClosedRings')}</Text>
<View style={styles.statValue}> <View style={styles.statValue}>
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}</Text> <Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}{t('fitnessRingsDetail.stats.daysUnit')}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -575,12 +585,12 @@ export default function FitnessRingsDetailScreen() {
{Platform.OS === 'ios' && ( {Platform.OS === 'ios' && (
<View style={styles.modalActions}> <View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}> <Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}>{t('fitnessRingsDetail.datePicker.cancel')}</Text>
</Pressable> </Pressable>
<Pressable onPress={() => { <Pressable onPress={() => {
onConfirmDate(pickerDate); onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}> }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
)} )}

View File

@@ -4,6 +4,7 @@ import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { createMedicationRecognitionTask } from '@/services/medications'; import { createMedicationRecognitionTask } from '@/services/medications';
import { getItem, setItem } from '@/utils/kvStore'; import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@@ -39,9 +40,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen'; const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
const captureSteps = [ const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true }, { key: 'front', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true }, { key: 'side', mandatory: true },
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false }, { key: 'aux', mandatory: false },
] as const; ] as const;
type CaptureKey = (typeof captureSteps)[number]['key']; type CaptureKey = (typeof captureSteps)[number]['key'];
@@ -51,6 +52,7 @@ type Shot = {
}; };
export default function MedicationAiCameraScreen() { export default function MedicationAiCameraScreen() {
const { t } = useI18n();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme]; const colors = Colors[scheme];
@@ -113,7 +115,14 @@ export default function MedicationAiCameraScreen() {
} }
}, [allRequiredCaptured]); }, [allRequiredCaptured]);
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]); const stepTitle = useMemo(
() =>
t('medications.aiCamera.steps.stepProgress', {
current: currentStepIndex + 1,
total: captureSteps.length,
}),
[currentStepIndex, t]
);
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动 // 计算固定的相机高度,不受按钮状态影响,避免布局跳动
const cameraHeight = useMemo(() => { const cameraHeight = useMemo(() => {
@@ -149,7 +158,7 @@ export default function MedicationAiCameraScreen() {
if (!result.canceled && result.assets?.length) { if (!result.canceled && result.assets?.length) {
const asset = result.assets[0]; const asset = result.assets[0];
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } })); setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步) // 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) { if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => { setTimeout(() => {
@@ -159,7 +168,10 @@ export default function MedicationAiCameraScreen() {
} }
} catch (error) { } catch (error) {
console.error('[MEDICATION_AI] pick image failed', error); console.error('[MEDICATION_AI] pick image failed', error);
Alert.alert('选择失败', '请重试或更换图片'); Alert.alert(
t('medications.aiCamera.alerts.pickFailed.title'),
t('medications.aiCamera.alerts.pickFailed.message')
);
} }
}; };
@@ -169,7 +181,7 @@ export default function MedicationAiCameraScreen() {
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 }); const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
if (photo?.uri) { if (photo?.uri) {
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } })); setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步) // 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) { if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => { setTimeout(() => {
@@ -179,7 +191,10 @@ export default function MedicationAiCameraScreen() {
} }
} catch (error) { } catch (error) {
console.error('[MEDICATION_AI] take picture failed', error); console.error('[MEDICATION_AI] take picture failed', error);
Alert.alert('拍摄失败', '请重试'); Alert.alert(
t('medications.aiCamera.alerts.captureFailed.title'),
t('medications.aiCamera.alerts.captureFailed.message')
);
} }
}; };
@@ -192,7 +207,10 @@ export default function MedicationAiCameraScreen() {
const handleStartRecognition = async () => { const handleStartRecognition = async () => {
// 检查必需照片是否完成 // 检查必需照片是否完成
if (!allRequiredCaptured) { if (!allRequiredCaptured) {
Alert.alert('照片不足', '请至少完成正面和背面拍摄'); Alert.alert(
t('medications.aiCamera.alerts.insufficientPhotos.title'),
t('medications.aiCamera.alerts.insufficientPhotos.message')
);
return; return;
} }
@@ -209,7 +227,9 @@ export default function MedicationAiCameraScreen() {
const [frontUpload, sideUpload, auxUpload] = await Promise.all([ const [frontUpload, sideUpload, auxUpload] = await Promise.all([
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }), upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }), upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null), shots.aux
? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' })
: Promise.resolve(null),
]); ]);
const task = await createMedicationRecognitionTask({ const task = await createMedicationRecognitionTask({
@@ -227,7 +247,10 @@ export default function MedicationAiCameraScreen() {
}); });
} catch (error: any) { } catch (error: any) {
console.error('[MEDICATION_AI] recognize failed', error); console.error('[MEDICATION_AI] recognize failed', error);
Alert.alert('创建任务失败', error?.message || '请检查网络后重试'); Alert.alert(
t('medications.aiCamera.alerts.taskFailed.title'),
error?.message || t('medications.aiCamera.alerts.taskFailed.defaultMessage')
);
} finally { } finally {
setCreatingTask(false); setCreatingTask(false);
} }
@@ -278,12 +301,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true} isInteractive={true}
> >
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" /> <Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}> <View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" /> <Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -440,12 +467,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true} isInteractive={true}
> >
<Ionicons name="camera" size={20} color="#0ea5e9" /> <Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}> <View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" /> <Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -470,7 +501,9 @@ export default function MedicationAiCameraScreen() {
) : ( ) : (
<> <>
<Ionicons name="checkmark-circle" size={20} color="#10b981" /> <Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</> </>
)} )}
</GlassView> </GlassView>
@@ -481,7 +514,9 @@ export default function MedicationAiCameraScreen() {
) : ( ) : (
<> <>
<Ionicons name="checkmark-circle" size={20} color="#10b981" /> <Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</> </>
)} )}
</View> </View>
@@ -501,12 +536,25 @@ export default function MedicationAiCameraScreen() {
if (!permission.granted) { if (!permission.granted) {
return ( return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}> <View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent /> <HeaderBar
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
/>
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}> <View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Text style={styles.permissionTitle}></Text> <Text style={styles.permissionTitle}>
<Text style={styles.permissionTip}></Text> {t('medications.aiCamera.permission.title')}
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}> </Text>
<Text style={styles.permissionBtnText}>访</Text> <Text style={styles.permissionTip}>
{t('medications.aiCamera.permission.description')}
</Text>
<TouchableOpacity
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
onPress={requestPermission}
>
<Text style={styles.permissionBtnText}>
{t('medications.aiCamera.permission.button')}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -524,14 +572,14 @@ export default function MedicationAiCameraScreen() {
<View style={styles.container}> <View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} /> <LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar <HeaderBar
title="AI 用药识别" title={t('medications.aiCamera.title')}
onBack={() => router.back()} onBack={() => router.back()}
transparent transparent
right={ right={
<TouchableOpacity <TouchableOpacity
onPress={() => setShowGuideModal(true)} onPress={() => setShowGuideModal(true)}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityLabel="查看拍摄说明" accessibilityLabel={t('medications.aiCamera.guideModal.title')}
> >
{isLiquidGlassAvailable() ? ( {isLiquidGlassAvailable() ? (
<GlassView <GlassView
@@ -556,8 +604,12 @@ export default function MedicationAiCameraScreen() {
<View style={styles.metaBadge}> <View style={styles.metaBadge}>
<Text style={styles.metaBadgeText}>{stepTitle}</Text> <Text style={styles.metaBadgeText}>{stepTitle}</Text>
</View> </View>
<Text style={styles.metaTitle}>{currentStep.title}</Text> <Text style={styles.metaTitle}>
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text> {t(`medications.aiCamera.steps.${currentStep.key}.title`)}
</Text>
<Text style={styles.metaSubtitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.subtitle`)}
</Text>
</View> </View>
<View style={styles.cameraCard}> <View style={styles.cameraCard}>
@@ -587,14 +639,22 @@ export default function MedicationAiCameraScreen() {
style={[styles.shotCard, active && styles.shotCardActive]} style={[styles.shotCard, active && styles.shotCardActive]}
> >
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}> <Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{step.title} {t(`medications.aiCamera.steps.${step.key}.title`)}
{!step.mandatory ? '(可选)' : ''} {!step.mandatory
? ` ${t('medications.aiCamera.steps.optional')}`
: ''}
</Text> </Text>
{shot ? ( {shot ? (
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" /> <Image
source={{ uri: shot.uri }}
style={styles.shotThumb}
contentFit="cover"
/>
) : ( ) : (
<View style={styles.shotPlaceholder}> <View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}></Text> <Text style={styles.shotPlaceholderText}>
{t('medications.aiCamera.steps.notTaken')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -617,12 +677,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true} isInteractive={true}
> >
<Ionicons name="images-outline" size={20} color="#0f172a" /> <Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}> <View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" /> <Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

@@ -7,11 +7,9 @@ import {
type TabConfig, type TabConfig,
} from '@/store/tabBarConfigSlice'; } from '@/store/tabBarConfigSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
ScrollView, ScrollView,
@@ -25,14 +23,14 @@ import {
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { palette } from '@/constants/Colors'; import { palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
export default function TabBarConfigScreen() { export default function TabBarConfigScreen() {
const { t } = useTranslation(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const safeAreaTop = useSafeAreaTop(60); const safeAreaTop = useSafeAreaTop(60);
const configs = useAppSelector(selectTabBarConfigs); const configs = useAppSelector(selectTabBarConfigs);
const isGlassAvailable = isLiquidGlassAvailable();
// 处理开关切换 // 处理开关切换
const handleToggle = useCallback( const handleToggle = useCallback(

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
@@ -17,6 +18,7 @@ import {
} from 'react-native'; } from 'react-native';
export default function StepsDetailScreen() { export default function StepsDetailScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
// 获取路由参数 // 获取路由参数
@@ -169,11 +171,11 @@ export default function StepsDetailScreen() {
// 活动等级配置 // 活动等级配置
const activityLevels = useMemo(() => [ const activityLevels = useMemo(() => [
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' }, { key: 'inactive', label: t('stepsDetail.activityLevel.levels.inactive'), minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' }, { key: 'light', label: t('stepsDetail.activityLevel.levels.light'), minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' }, { key: 'moderate', label: t('stepsDetail.activityLevel.levels.moderate'), minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' } { key: 'very_active', label: t('stepsDetail.activityLevel.levels.very_active'), minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], []); ], [t]);
// 计算当前活动等级 // 计算当前活动等级
const currentActivityLevel = useMemo(() => { const currentActivityLevel = useMemo(() => {
@@ -211,7 +213,7 @@ export default function StepsDetailScreen() {
/> />
<HeaderBar <HeaderBar
title="步数详情" title={t('stepsDetail.title')}
/> />
<ScrollView <ScrollView
@@ -233,23 +235,23 @@ export default function StepsDetailScreen() {
<View style={styles.statsCard}> <View style={styles.statsCard}>
{isLoading ? ( {isLoading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('stepsDetail.loading')}</Text>
</View> </View>
) : ( ) : (
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text> <Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('stepsDetail.stats.totalSteps')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text> <Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('stepsDetail.stats.averagePerHour')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'} {mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text> </Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('stepsDetail.stats.mostActiveTime')}</Text>
</View> </View>
</View> </View>
)} )}
@@ -258,7 +260,7 @@ export default function StepsDetailScreen() {
{/* 详细柱状图卡片 */} {/* 详细柱状图卡片 */}
<View style={styles.chartCard}> <View style={styles.chartCard}>
<View style={styles.chartHeader}> <View style={styles.chartHeader}>
<Text style={styles.chartTitle}></Text> <Text style={styles.chartTitle}>{t('stepsDetail.chart.title')}</Text>
<Text style={styles.chartSubtitle}> <Text style={styles.chartSubtitle}>
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')} {dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
</Text> </Text>
@@ -290,7 +292,7 @@ export default function StepsDetailScreen() {
))} ))}
</View> </View>
<Text style={styles.averageLineLabel}> <Text style={styles.averageLineLabel}>
{averageHourlySteps} {t('stepsDetail.chart.averageLabel', { steps: averageHourlySteps })}
</Text> </Text>
</View> </View>
)} )}
@@ -354,9 +356,9 @@ export default function StepsDetailScreen() {
{/* 底部时间轴标签 */} {/* 底部时间轴标签 */}
<View style={styles.timeLabels}> <View style={styles.timeLabels}>
<Text style={styles.timeLabel}>0:00</Text> <Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.midnight')}</Text>
<Text style={styles.timeLabel}>12:00</Text> <Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.noon')}</Text>
<Text style={styles.timeLabel}>24:00</Text> <Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.nextDay')}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -366,7 +368,7 @@ export default function StepsDetailScreen() {
{/* 活动级别文本 */} {/* 活动级别文本 */}
<Text style={styles.activityMainText}></Text> <Text style={styles.activityMainText}>{t('stepsDetail.activityLevel.currentActivity')}</Text>
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text> <Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
{/* 进度条 */} {/* 进度条 */}
@@ -388,14 +390,14 @@ export default function StepsDetailScreen() {
<View style={styles.stepsInfoContainer}> <View style={styles.stepsInfoContainer}>
<View style={styles.currentStepsInfo}> <View style={styles.currentStepsInfo}>
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text> <Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text>
<Text style={styles.stepsLabel}></Text> <Text style={styles.stepsLabel}>{t('stepsDetail.activityLevel.progress.current')}</Text>
</View> </View>
<View style={styles.nextStepsInfo}> <View style={styles.nextStepsInfo}>
<Text style={styles.stepsValue}> <Text style={styles.stepsValue}>
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'} {nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'}
</Text> </Text>
<Text style={styles.stepsLabel}> <Text style={styles.stepsLabel}>
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'} {nextActivityLevel ? t('stepsDetail.activityLevel.progress.nextLevel', { level: nextActivityLevel.label }) : t('stepsDetail.activityLevel.progress.highestLevel')}
</Text> </Text>
</View> </View>
</View> </View>

View File

@@ -3,6 +3,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
@@ -20,6 +21,7 @@ import {
import { Swipeable } from 'react-native-gesture-handler'; import { Swipeable } from 'react-native-gesture-handler';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -28,6 +30,7 @@ interface WaterDetailProps {
} }
const WaterDetail: React.FC<WaterDetailProps> = () => { const WaterDetail: React.FC<WaterDetailProps> = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
@@ -37,22 +40,14 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
const [dailyGoal, setDailyGoal] = useState<string>('2000'); const [dailyGoal, setDailyGoal] = useState<string>('2000');
const [quickAddAmount, setQuickAddAmount] = useState<string>('250'); const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
// Remove modal states as they are now in separate settings page
// 使用新的 hook 来处理指定日期的饮水数据 // 使用新的 hook 来处理指定日期的饮水数据
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate); const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
// 处理设置按钮点击 - 跳转到设置页面 // 处理设置按钮点击 - 跳转到设置页面
const handleSettingsPress = () => { const handleSettingsPress = () => {
router.push('/water/settings'); router.push('/water/settings');
}; };
// Remove all modal-related functions as they are now in separate settings page
// 删除饮水记录 // 删除饮水记录
const handleDeleteRecord = async (recordId: string) => { const handleDeleteRecord = async (recordId: string) => {
await removeWaterRecord(recordId); await removeWaterRecord(recordId);
@@ -70,13 +65,17 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
setDailyGoal(dailyWaterGoal.toString()); setDailyGoal(dailyWaterGoal.toString());
} }
} catch (error) { } catch (error) {
console.error('加载用户偏好设置失败:', error); console.error(t('waterDetail.loadingUserPreferences'), error);
} }
}; };
loadUserPreferences(); loadUserPreferences();
}, [dailyWaterGoal]); }, [dailyWaterGoal]);
const totalAmount = waterRecords?.reduce((sum, record) => sum + record.amount, 0) || 0;
const currentGoal = dailyWaterGoal || 2000;
const progress = Math.min(100, (totalAmount / currentGoal) * 100);
// 新增:饮水记录卡片组件 // 新增:饮水记录卡片组件
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => { const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
const swipeableRef = React.useRef<Swipeable>(null); const swipeableRef = React.useRef<Swipeable>(null);
@@ -84,15 +83,15 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
// 处理删除操作 // 处理删除操作
const handleDelete = () => { const handleDelete = () => {
Alert.alert( Alert.alert(
'确认删除', t('waterDetail.deleteConfirm.title'),
'确定要删除这条饮水记录吗?此操作无法撤销。', t('waterDetail.deleteConfirm.message'),
[ [
{ {
text: '取消', text: t('waterDetail.deleteConfirm.cancel'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: '删除', text: t('waterDetail.deleteConfirm.confirm'),
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
onDelete(); onDelete();
@@ -112,7 +111,6 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="trash" size={20} color="#FFFFFF" /> <Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteSwipeButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
@@ -125,29 +123,29 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
rightThreshold={40} rightThreshold={40}
overshootRight={false} overshootRight={false}
> >
<View style={[styles.recordCard, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}> <View style={styles.recordCard}>
<View style={styles.recordMainContent}> <View style={styles.recordMainContent}>
<View style={[styles.recordIconContainer, { backgroundColor: colorTokens.background }]}> <View style={styles.recordIconContainer}>
<Image <Image
source={require('@/assets/images/icons/IconGlass.png')} source={require('@/assets/images/icons/IconGlass.png')}
style={styles.recordIcon} style={styles.recordIcon}
/> />
</View> </View>
<View style={styles.recordInfo}> <View style={styles.recordInfo}>
<Text style={[styles.recordLabel, { color: colorTokens.text }]}></Text> <Text style={styles.recordLabel}>{t('waterDetail.water')}</Text>
<View style={styles.recordTimeContainer}> <View style={styles.recordTimeContainer}>
<Ionicons name="time-outline" size={14} color={colorTokens.textSecondary} /> <Ionicons name="time-outline" size={14} color="#6f7ba7" />
<Text style={[styles.recordTimeText, { color: colorTokens.textSecondary }]}> <Text style={styles.recordTimeText}>
{dayjs(record.recordedAt || record.createdAt).format('HH:mm')} {dayjs(record.recordedAt || record.createdAt).format('HH:mm')}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.recordAmountContainer}> <View style={styles.recordAmountContainer}>
<Text style={[styles.recordAmount, { color: colorTokens.text }]}>{record.amount}ml</Text> <Text style={styles.recordAmount}>{record.amount}ml</Text>
</View> </View>
</View> </View>
{record.note && ( {record.note && (
<Text style={[styles.recordNote, { color: colorTokens.textSecondary }]}>{record.note}</Text> <Text style={styles.recordNote}>{record.note}</Text>
)} )}
</View> </View>
</Swipeable> </Swipeable>
@@ -157,32 +155,47 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 背景渐变 */} {/* 背景 */}
<LinearGradient <LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']} colors={['#f3f4fb', '#f3f4fb']}
style={styles.gradientBackground} style={StyleSheet.absoluteFillObject}
start={{ x: 0, y: 0 }} />
end={{ x: 0, y: 1 }} {/* 顶部装饰性渐变 - 模仿挑战页面的柔和背景感 */}
<LinearGradient
colors={['rgba(229, 252, 254, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/> />
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar <HeaderBar
title="饮水详情" title={t('waterDetail.title')}
onBack={() => { onBack={() => router.back()}
// 这里会通过路由自动处理返回
router.back();
}}
right={ right={
<TouchableOpacity isLiquidGlassAvailable() ? (
style={styles.settingsButton} <TouchableOpacity
onPress={handleSettingsPress} onPress={handleSettingsPress}
activeOpacity={0.7} activeOpacity={0.7}
> style={styles.settingsButtonWrapper}
<Ionicons name="settings-outline" size={24} color={colorTokens.text} /> >
</TouchableOpacity> <GlassView
style={styles.settingsButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.settingsButtonFallback}
onPress={handleSettingsPress}
activeOpacity={0.7}
>
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
</TouchableOpacity>
)
} }
/> />
@@ -197,13 +210,37 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
}]} }]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={styles.headerBlock}>
{/* 第二部分:饮水记录 */} <Text style={styles.pageTitle}>
<View style={styles.section}> {selectedDate ? dayjs(selectedDate).format('MM月DD日') : t('waterDetail.today')}
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}
</Text> </Text>
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
</View>
{/* 进度卡片 */}
<View style={styles.progressCard}>
<View style={styles.progressInfo}>
<View>
<Text style={styles.progressLabel}>{t('waterDetail.total')}</Text>
<Text style={styles.progressValue}>{totalAmount}<Text style={styles.progressUnit}>ml</Text></Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.progressLabel}>{t('waterDetail.goal')}</Text>
<Text style={styles.progressGoalValue}>{currentGoal}<Text style={styles.progressUnit}>ml</Text></Text>
</View>
</View>
<View style={styles.progressBarBg}>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressBarFill, { width: `${progress}%` }]}
/>
</View>
</View>
{/* 记录列表 */}
<View style={styles.section}>
{waterRecords && waterRecords.length > 0 ? ( {waterRecords && waterRecords.length > 0 ? (
<View style={styles.recordsList}> <View style={styles.recordsList}>
{waterRecords.map((record) => ( {waterRecords.map((record) => (
@@ -213,29 +250,20 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
onDelete={() => handleDeleteRecord(record.id)} onDelete={() => handleDeleteRecord(record.id)}
/> />
))} ))}
{/* 总计显示 */}
<View style={[styles.recordsSummary, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Text style={[styles.summaryText, { color: colorTokens.text }]}>
{waterRecords.reduce((sum, record) => sum + record.amount, 0)}ml
</Text>
<Text style={[styles.summaryGoal, { color: colorTokens.textSecondary }]}>
{dailyWaterGoal}ml
</Text>
</View>
</View> </View>
) : ( ) : (
<View style={styles.noRecordsContainer}> <View style={styles.noRecordsContainer}>
<Ionicons name="water-outline" size={48} color={colorTokens.textSecondary} /> <Image
<Text style={[styles.noRecordsText, { color: colorTokens.textSecondary }]}></Text> source={require('@/assets/images/icons/IconGlass.png')}
<Text style={[styles.noRecordsSubText, { color: colorTokens.textSecondary }]}>&quot;&quot;</Text> style={{ width: 60, height: 60, opacity: 0.5, marginBottom: 16 }}
/>
<Text style={styles.noRecordsText}>{t('waterDetail.noRecords')}</Text>
<Text style={styles.noRecordsSubText}>{t('waterDetail.noRecordsSubtitle')}</Text>
</View> </View>
)} )}
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
{/* All modals have been moved to the separate water-settings page */}
</View> </View>
); );
}; };
@@ -245,32 +273,12 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: '#f3f4fb', backgroundColor: '#f3f4fb',
}, },
gradientBackground: { topGradient: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, height: 300,
},
decorativeCircle1: {
position: 'absolute',
top: 80,
right: 30,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#4F5BD5',
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
bottom: 100,
left: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#4F5BD5',
opacity: 0.06,
}, },
keyboardAvoidingView: { keyboardAvoidingView: {
flex: 1, flex: 1,
@@ -279,54 +287,107 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
paddingBottom: 40,
},
headerBlock: {
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: 20, marginTop: 10,
},
section: {
marginBottom: 36,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 24, marginBottom: 24,
letterSpacing: -0.5,
color: '#1c1f3a',
}, },
subsectionTitle: { pageTitle: {
fontSize: 28,
fontWeight: '800',
color: '#1c1f3a',
fontFamily: 'AliBold',
marginBottom: 4,
},
pageSubtitle: {
fontSize: 16,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
// 进度卡片
progressCard: {
marginHorizontal: 24,
marginBottom: 32,
padding: 24,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.1)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 16,
},
progressLabel: {
fontSize: 14,
color: '#6f7ba7',
marginBottom: 6,
fontFamily: 'AliRegular',
},
progressValue: {
fontSize: 32,
fontWeight: '800',
color: '#4F5BD5',
fontFamily: 'AliBold',
lineHeight: 32,
},
progressGoalValue: {
fontSize: 24,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
lineHeight: 32,
},
progressUnit: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
marginBottom: 16,
letterSpacing: -0.3,
color: '#1c1f3a',
},
sectionSubtitle: {
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
color: '#6f7ba7', color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
}, },
// 饮水记录相关样式 progressBarBg: {
height: 12,
backgroundColor: '#F0F2F5',
borderRadius: 6,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 6,
},
section: {
paddingHorizontal: 24,
},
// 记录列表样式
recordsList: { recordsList: {
gap: 16, gap: 16,
}, },
recordCardContainer: { recordCardContainer: {
// iOS 阴影效果 - 增强阴影效果 shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowOffset: { width: 0, height: 8 }, shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.16, shadowOpacity: 0.12,
shadowRadius: 16, shadowRadius: 12,
// Android 阴影效果 elevation: 4,
elevation: 6, marginBottom: 2,
}, },
recordCard: { recordCard: {
borderRadius: 20, borderRadius: 24,
padding: 18, padding: 18,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
recordMainContent: { recordMainContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
}, },
recordIconContainer: { recordIconContainer: {
width: 48, width: 48,
@@ -334,7 +395,7 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.08)', backgroundColor: '#f5f6ff',
}, },
recordIcon: { recordIcon: {
width: 24, width: 24,
@@ -345,15 +406,21 @@ const styles = StyleSheet.create({
marginLeft: 16, marginLeft: 16,
}, },
recordLabel: { recordLabel: {
fontSize: 17, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
marginBottom: 6, marginBottom: 4,
fontFamily: 'AliBold',
}, },
recordTimeContainer: { recordTimeContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 4,
},
recordTimeText: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
}, },
recordAmountContainer: { recordAmountContainer: {
alignItems: 'flex-end', alignItems: 'flex-end',
@@ -362,364 +429,74 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#4F5BD5', color: '#4F5BD5',
}, fontFamily: 'AliBold',
deleteSwipeButton: {
backgroundColor: '#EF4444',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 16,
marginLeft: 12,
},
deleteSwipeButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
},
recordTimeText: {
fontSize: 13,
fontWeight: '500',
color: '#6f7ba7',
}, },
recordNote: { recordNote: {
marginTop: 12, marginTop: 14,
padding: 12, padding: 12,
backgroundColor: 'rgba(79, 91, 213, 0.04)', backgroundColor: '#F8F9FC',
borderRadius: 12, borderRadius: 12,
fontSize: 14, fontSize: 13,
fontStyle: 'normal', lineHeight: 18,
lineHeight: 20,
color: '#5f6a97', color: '#5f6a97',
fontFamily: 'AliRegular',
}, },
recordsSummary: { deleteSwipeButton: {
marginTop: 24, backgroundColor: '#FF6B6B',
padding: 20, justifyContent: 'center',
borderRadius: 20,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
width: 70,
height: '100%',
borderRadius: 24,
marginLeft: 12,
}, },
summaryText: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
},
summaryGoal: {
fontSize: 14,
fontWeight: '600',
color: '#6f7ba7',
},
noRecordsContainer: { noRecordsContainer: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 60, paddingVertical: 60,
gap: 20, backgroundColor: '#ffffff',
borderRadius: 28,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
}, },
noRecordsText: { noRecordsText: {
fontSize: 17, fontSize: 16,
fontWeight: '600', fontWeight: '600',
lineHeight: 24, color: '#1c1f3a',
color: '#6f7ba7', marginBottom: 8,
fontFamily: 'AliBold',
}, },
noRecordsSubText: { noRecordsSubText: {
fontSize: 14, fontSize: 14,
textAlign: 'center',
lineHeight: 20,
color: '#9ba3c7', color: '#9ba3c7',
fontFamily: 'AliRegular',
}, },
modalBackdrop: {
...StyleSheet.absoluteFillObject, // Settings Button
backgroundColor: 'rgba(0,0,0,0.4)', settingsButtonWrapper: {
width: 40,
height: 40,
borderRadius: 20,
overflow: 'hidden',
}, },
modalSheet: { settingsButtonGlass: {
position: 'absolute', width: 40,
left: 0, height: 40,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
// iOS 阴影效果
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
// Android 阴影效果
elevation: 16,
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
pickerContainer: {
height: 200,
marginBottom: 20,
},
picker: {
height: 200,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
minWidth: 80,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
}, },
modalBtnPrimary: { settingsButtonFallback: {
// backgroundColor will be set dynamically
},
modalBtnText: {
fontSize: 16,
fontWeight: '600',
},
modalBtnTextPrimary: {
// color will be set dynamically
},
settingsButton: {
width: 40, width: 40,
height: 40, height: 40,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 20, borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.24)', backgroundColor: '#ffffff',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)', borderColor: 'rgba(0,0,0,0.05)',
},
settingsModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
settingsModalTitle: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
settingsMenuContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
settingsMenuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
settingsMenuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
settingsIconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
settingsMenuItemContent: {
flex: 1,
},
settingsMenuItemTitle: {
fontSize: 15,
fontWeight: '500',
marginBottom: 2,
},
settingsMenuItemSubtitle: {
fontSize: 12,
marginBottom: 4,
},
settingsMenuItemValue: {
fontSize: 14,
},
// 喝水提醒配置弹窗样式
waterReminderModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
waterReminderContent: {
flex: 1,
marginBottom: 20,
},
waterReminderSection: {
marginBottom: 24,
},
waterReminderSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
waterReminderSectionTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
waterReminderSectionTitle: {
fontSize: 16,
fontWeight: '600',
},
waterReminderSectionDesc: {
fontSize: 14,
lineHeight: 20,
marginTop: 4,
},
timeRangeContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 16,
},
timePickerContainer: {
flex: 1,
},
timeLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
timePicker: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
timePickerText: {
fontSize: 16,
fontWeight: '500',
},
timePickerIcon: {
opacity: 0.6,
},
intervalContainer: {
marginTop: 16,
},
intervalPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
intervalPicker: {
height: 120,
},
// 时间选择器弹窗样式
timePickerModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
timePickerContent: {
flex: 1,
marginBottom: 20,
},
timePickerSection: {
marginBottom: 20,
},
timePickerLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
hourPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
hourPicker: {
height: 160,
},
timeRangePreview: {
backgroundColor: '#F0F8FF',
borderRadius: 8,
padding: 16,
marginTop: 16,
alignItems: 'center',
},
timeRangePreviewLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
timeRangePreviewText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
timeRangeWarning: {
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
lineHeight: 18,
}, },
}); });

View File

@@ -22,9 +22,11 @@ import {
} from 'react-native'; } from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterReminderSettings: React.FC = () => { const WaterReminderSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -71,9 +73,9 @@ const WaterReminderSettings: React.FC = () => {
setStartTimePickerVisible(false); setStartTimePickerVisible(false);
} else { } else {
Alert.alert( Alert.alert(
'时间设置提示', t('waterReminderSettings.alerts.timeValidation.title'),
'开始时间不能晚于或等于结束时间,请重新选择', t('waterReminderSettings.alerts.timeValidation.startTimeInvalid'),
[{ text: '确定' }] [{ text: t('waterReminderSettings.buttons.confirm') }]
); );
} }
}; };
@@ -91,9 +93,9 @@ const WaterReminderSettings: React.FC = () => {
setEndTimePickerVisible(false); setEndTimePickerVisible(false);
} else { } else {
Alert.alert( Alert.alert(
'时间设置提示', t('waterReminderSettings.alerts.timeValidation.title'),
'结束时间不能早于或等于开始时间,请重新选择', t('waterReminderSettings.alerts.timeValidation.endTimeInvalid'),
[{ text: '确定' }] [{ text: t('waterReminderSettings.buttons.confirm') }]
); );
} }
}; };
@@ -125,18 +127,28 @@ const WaterReminderSettings: React.FC = () => {
if (waterReminderSettings.enabled) { if (waterReminderSettings.enabled) {
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`; const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
const intervalInfo = `${waterReminderSettings.interval}分钟`; const intervalInfo = `${waterReminderSettings.interval}${t('waterReminderSettings.labels.minutes')}`;
Alert.alert( Alert.alert(
'设置成功', t('waterReminderSettings.alerts.success.enabled'),
`喝水提醒已开启\n\n时间段${timeInfo}\n提醒间隔${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`, t('waterReminderSettings.alerts.success.enabledMessage', {
[{ text: '确定', onPress: () => router.back() }] timeRange: timeInfo,
interval: intervalInfo
}),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
); );
} else { } else {
Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]); Alert.alert(
t('waterReminderSettings.alerts.success.disabled'),
t('waterReminderSettings.alerts.success.disabledMessage'),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
);
} }
} catch (error) { } catch (error) {
console.error('保存喝水提醒设置失败:', error); console.error('保存喝水提醒设置失败:', error);
Alert.alert('保存失败', '无法保存喝水提醒设置,请重试'); Alert.alert(
t('waterReminderSettings.alerts.error.title'),
t('waterReminderSettings.alerts.error.message')
);
} }
}; };
@@ -176,7 +188,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
<HeaderBar <HeaderBar
title="喝水提醒" title={t('waterReminderSettings.title')}
onBack={() => { onBack={() => {
router.back(); router.back();
}} }}
@@ -198,7 +210,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.waterReminderSectionHeader}> <View style={styles.waterReminderSectionHeader}>
<View style={styles.waterReminderSectionTitleContainer}> <View style={styles.waterReminderSectionTitleContainer}>
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} /> <Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.notifications')}</Text>
</View> </View>
<Switch <Switch
value={waterReminderSettings.enabled} value={waterReminderSettings.enabled}
@@ -208,7 +220,7 @@ const WaterReminderSettings: React.FC = () => {
/> />
</View> </View>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}> <Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.notifications')}
</Text> </Text>
</View> </View>
@@ -216,15 +228,15 @@ const WaterReminderSettings: React.FC = () => {
{waterReminderSettings.enabled && ( {waterReminderSettings.enabled && (
<> <>
<View style={styles.waterReminderSection}> <View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.timeRange')}</Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}> <Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.timeRange')}
</Text> </Text>
<View style={styles.timeRangeContainer}> <View style={styles.timeRangeContainer}>
{/* 开始时间 */} {/* 开始时间 */}
<View style={styles.timePickerContainer}> <View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
<Pressable <Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]} style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openStartTimePicker} onPress={openStartTimePicker}
@@ -236,7 +248,7 @@ const WaterReminderSettings: React.FC = () => {
{/* 结束时间 */} {/* 结束时间 */}
<View style={styles.timePickerContainer}> <View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
<Pressable <Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]} style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openEndTimePicker} onPress={openEndTimePicker}
@@ -250,9 +262,9 @@ const WaterReminderSettings: React.FC = () => {
{/* 提醒间隔设置 */} {/* 提醒间隔设置 */}
<View style={styles.waterReminderSection}> <View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.interval')}</Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}> <Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
30-120 {t('waterReminderSettings.descriptions.interval')}
</Text> </Text>
<View style={styles.intervalContainer}> <View style={styles.intervalContainer}>
@@ -263,7 +275,7 @@ const WaterReminderSettings: React.FC = () => {
style={styles.intervalPicker} style={styles.intervalPicker}
> >
{[30, 45, 60, 90, 120, 150, 180].map(interval => ( {[30, 45, 60, 90, 120, 150, 180].map(interval => (
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} /> <Picker.Item key={interval} label={`${interval}${t('waterReminderSettings.labels.minutes')}`} value={interval} />
))} ))}
</Picker> </Picker>
</View> </View>
@@ -279,7 +291,7 @@ const WaterReminderSettings: React.FC = () => {
onPress={handleWaterReminderSave} onPress={handleWaterReminderSave}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.labels.saveSettings')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
@@ -295,11 +307,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}> <View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
<View style={styles.timePickerContent}> <View style={styles.timePickerContent}>
<View style={styles.timePickerSection}> <View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
<View style={styles.hourPickerContainer}> <View style={styles.hourPickerContainer}>
<Picker <Picker
selectedValue={tempStartHour} selectedValue={tempStartHour}
@@ -314,12 +326,12 @@ const WaterReminderSettings: React.FC = () => {
</View> </View>
<View style={styles.timeRangePreview}> <View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}> <Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime} {String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
</Text> </Text>
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && ( {!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
<Text style={styles.timeRangeWarning}> </Text> <Text style={styles.timeRangeWarning}> {t('waterReminderSettings.alerts.timeValidation.startTimeInvalid')}</Text>
)} )}
</View> </View>
</View> </View>
@@ -329,13 +341,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setStartTimePickerVisible(false)} onPress={() => setStartTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]} style={[styles.modalBtn, { backgroundColor: 'white' }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={confirmStartTime} onPress={confirmStartTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -351,11 +363,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}> <View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
<View style={styles.timePickerContent}> <View style={styles.timePickerContent}>
<View style={styles.timePickerSection}> <View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
<View style={styles.hourPickerContainer}> <View style={styles.hourPickerContainer}>
<Picker <Picker
selectedValue={tempEndHour} selectedValue={tempEndHour}
@@ -370,12 +382,12 @@ const WaterReminderSettings: React.FC = () => {
</View> </View>
<View style={styles.timeRangePreview}> <View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}> <Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00 {waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
</Text> </Text>
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && ( {!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
<Text style={styles.timeRangeWarning}> </Text> <Text style={styles.timeRangeWarning}> {t('waterReminderSettings.alerts.timeValidation.endTimeInvalid')}</Text>
)} )}
</View> </View>
</View> </View>
@@ -385,13 +397,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setEndTimePickerVisible(false)} onPress={() => setEndTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]} style={[styles.modalBtn, { backgroundColor: 'white' }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={confirmEndTime} onPress={confirmEndTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@@ -21,9 +21,11 @@ import {
} from 'react-native'; } from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterSettings: React.FC = () => { const WaterSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -74,7 +76,10 @@ const WaterSettings: React.FC = () => {
setGoalModalVisible(false); setGoalModalVisible(false);
// 这里可以添加保存到本地存储或发送到后端的逻辑 // 这里可以添加保存到本地存储或发送到后端的逻辑
Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`); Alert.alert(
t('waterSettings.alerts.goalSuccess.title'),
t('waterSettings.alerts.goalSuccess.message', { amount: tempGoal })
);
}; };
// 处理快速添加默认值确认 // 处理快速添加默认值确认
@@ -84,9 +89,15 @@ const WaterSettings: React.FC = () => {
try { try {
await setQuickWaterAmount(tempQuickAdd); await setQuickWaterAmount(tempQuickAdd);
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`); Alert.alert(
t('waterSettings.alerts.quickAddSuccess.title'),
t('waterSettings.alerts.quickAddSuccess.message', { amount: tempQuickAdd })
);
} catch { } catch {
Alert.alert('设置失败', '无法保存快速添加默认值,请重试'); Alert.alert(
t('waterSettings.alerts.quickAddFailed.title'),
t('waterSettings.alerts.quickAddFailed.message')
);
} }
}; };
@@ -101,7 +112,7 @@ const WaterSettings: React.FC = () => {
const reminderSettings = await getWaterReminderSettings(); const reminderSettings = await getWaterReminderSettings();
setWaterReminderSettings(reminderSettings); setWaterReminderSettings(reminderSettings);
} catch (error) { } catch (error) {
console.error('加载用户偏好设置失败:', error); console.error('Failed to load user preferences:', error);
} }
}, []); }, []);
@@ -132,7 +143,7 @@ const WaterSettings: React.FC = () => {
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
<HeaderBar <HeaderBar
title="饮水设置" title={t('waterSettings.title')}
onBack={() => { onBack={() => {
router.back(); router.back();
}} }}
@@ -156,8 +167,8 @@ const WaterSettings: React.FC = () => {
<Ionicons name="flag-outline" size={20} color="#9370DB" /> <Ionicons name="flag-outline" size={20} color="#9370DB" />
</View> </View>
<View style={styles.settingsMenuItemContent}> <View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text> <Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}{t('waterSettings.labels.ml')}</Text>
</View> </View>
</View> </View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" /> <Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -169,11 +180,11 @@ const WaterSettings: React.FC = () => {
<Ionicons name="add-outline" size={20} color="#9370DB" /> <Ionicons name="add-outline" size={20} color="#9370DB" />
</View> </View>
<View style={styles.settingsMenuItemContent}> <View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}> <Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
"+" {t('waterSettings.descriptions.quickAdd')}
</Text> </Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text> <Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}{t('waterSettings.labels.ml')}</Text>
</View> </View>
</View> </View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" /> <Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -185,12 +196,19 @@ const WaterSettings: React.FC = () => {
<Ionicons name="notifications-outline" size={20} color="#3498DB" /> <Ionicons name="notifications-outline" size={20} color="#3498DB" />
</View> </View>
<View style={styles.settingsMenuItemContent}> <View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.reminder')}</Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}> <Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
{t('waterSettings.descriptions.reminder')}
</Text> </Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}> <Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>
{waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'} {waterReminderSettings.enabled
? t('waterSettings.status.reminderEnabled', {
startTime: waterReminderSettings.startTime,
endTime: waterReminderSettings.endTime,
interval: waterReminderSettings.interval
})
: t('waterSettings.labels.disabled')
}
</Text> </Text>
</View> </View>
</View> </View>
@@ -211,7 +229,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
<View style={styles.modalSheet}> <View style={styles.modalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<View style={styles.pickerContainer}> <View style={styles.pickerContainer}>
<Picker <Picker
selectedValue={tempGoal} selectedValue={tempGoal}
@@ -219,7 +237,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker} style={styles.picker}
> >
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => ( {Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
<Picker.Item key={goal} label={`${goal}ml`} value={goal} /> <Picker.Item key={goal} label={`${goal}${t('waterSettings.labels.ml')}`} value={goal} />
))} ))}
</Picker> </Picker>
</View> </View>
@@ -228,13 +246,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setGoalModalVisible(false)} onPress={() => setGoalModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleGoalConfirm} onPress={handleGoalConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -250,7 +268,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
<View style={styles.modalSheet}> <View style={styles.modalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
<View style={styles.pickerContainer}> <View style={styles.pickerContainer}>
<Picker <Picker
selectedValue={tempQuickAdd} selectedValue={tempQuickAdd}
@@ -258,7 +276,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker} style={styles.picker}
> >
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => ( {Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
<Picker.Item key={amount} label={`${amount}ml`} value={amount} /> <Picker.Item key={amount} label={`${amount}${t('waterSettings.labels.ml')}`} value={amount} />
))} ))}
</Picker> </Picker>
</View> </View>
@@ -267,13 +285,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setQuickAddModalVisible(false)} onPress={() => setQuickAddModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleQuickAddConfirm} onPress={handleQuickAddConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { appStoreReviewService } from '@/services/appStoreReview'; import { appStoreReviewService } from '@/services/appStoreReview';
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice'; import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
@@ -24,6 +25,7 @@ import {
} from 'react-native'; } from 'react-native';
export default function WeightRecordsPage() { export default function WeightRecordsPage() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -43,7 +45,7 @@ export default function WeightRecordsPage() {
try { try {
await dispatch(fetchWeightHistory() as any); await dispatch(fetchWeightHistory() as any);
} catch (error) { } catch (error) {
console.error('加载体重历史失败:', error); console.error(t('weightRecords.loadingHistory'), error);
} }
}, [dispatch]); }, [dispatch]);
@@ -92,15 +94,15 @@ export default function WeightRecordsPage() {
await dispatch(deleteWeightRecord(id) as any); await dispatch(deleteWeightRecord(id) as any);
await loadWeightHistory(); await loadWeightHistory();
} catch (error) { } catch (error) {
console.error('删除体重记录失败:', error); console.error(t('weightRecords.alerts.deleteFailed'), error);
Alert.alert('错误', '删除体重记录失败,请重试'); Alert.alert('错误', t('weightRecords.alerts.deleteFailed'));
} }
}; };
const handleWeightSave = async () => { const handleWeightSave = async () => {
const weight = parseFloat(inputWeight); const weight = parseFloat(inputWeight);
if (isNaN(weight) || weight <= 0 || weight > 500) { if (isNaN(weight) || weight <= 0 || weight > 500) {
alert('请输入有效的体重值0-500kg'); alert(t('weightRecords.alerts.invalidWeight'));
return; return;
} }
@@ -130,8 +132,8 @@ export default function WeightRecordsPage() {
setEditingRecord(null); setEditingRecord(null);
await loadWeightHistory(); await loadWeightHistory();
} catch (error) { } catch (error) {
console.error('保存体重失败:', error); console.error(t('weightRecords.alerts.saveFailed'), error);
Alert.alert('错误', '保存体重失败,请重试'); Alert.alert('错误', t('weightRecords.alerts.saveFailed'));
} }
}; };
@@ -190,7 +192,7 @@ export default function WeightRecordsPage() {
/> />
<HeaderBar <HeaderBar
title="体重记录" title={t('weightRecords.title')}
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}> right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
<Ionicons name="add" size={24} color="#192126" /> <Ionicons name="add" size={24} color="#192126" />
</TouchableOpacity>} </TouchableOpacity>}
@@ -204,27 +206,27 @@ export default function WeightRecordsPage() {
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text> <Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('weightRecords.stats.totalLoss')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text> <Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('weightRecords.stats.currentWeight')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text> <Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}> <View style={styles.statLabelContainer}>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('weightRecords.stats.initialWeight')}</Text>
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}> <TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" /> <Ionicons name="create-outline" size={12} color="#FF9500" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text> <Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}> <View style={styles.statLabelContainer}>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('weightRecords.stats.targetWeight')}</Text>
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}> <TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" /> <Ionicons name="create-outline" size={12} color="#FF9500" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -282,8 +284,8 @@ export default function WeightRecordsPage() {
) : ( ) : (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<View style={styles.emptyContent}> <View style={styles.emptyContent}>
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}>{t('weightRecords.empty.title')}</Text>
<Text style={styles.emptySubtext}></Text> <Text style={styles.emptySubtext}>{t('weightRecords.empty.subtitle')}</Text>
</View> </View>
</View> </View>
)} )}
@@ -309,10 +311,10 @@ export default function WeightRecordsPage() {
<Ionicons name="close" size={24} color={themeColors.text} /> <Ionicons name="close" size={24} color={themeColors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.modalTitle, { color: themeColors.text }]}> <Text style={[styles.modalTitle, { color: themeColors.text }]}>
{pickerType === 'current' && '记录体重'} {pickerType === 'current' && t('weightRecords.modal.recordWeight')}
{pickerType === 'initial' && '编辑初始体重'} {pickerType === 'initial' && t('weightRecords.modal.editInitialWeight')}
{pickerType === 'target' && '编辑目标体重'} {pickerType === 'target' && t('weightRecords.modal.editTargetWeight')}
{pickerType === 'edit' && '编辑体重记录'} {pickerType === 'edit' && t('weightRecords.modal.editRecord')}
</Text> </Text>
<View style={{ width: 24 }} /> <View style={{ width: 24 }} />
</View> </View>
@@ -329,21 +331,21 @@ export default function WeightRecordsPage() {
</View> </View>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}> <Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
{inputWeight || '输入体重'} {inputWeight || t('weightRecords.modal.inputPlaceholder')}
</Text> </Text>
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text> <Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.modal.unit')}</Text>
</View> </View>
</View> </View>
{/* Weight Range Hint */} {/* Weight Range Hint */}
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}> <Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
0-500 {t('weightRecords.modal.inputHint')}
</Text> </Text>
</View> </View>
{/* Quick Selection */} {/* Quick Selection */}
<View style={styles.quickSelectionSection}> <View style={styles.quickSelectionSection}>
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}></Text> <Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}>{t('weightRecords.modal.quickSelection')}</Text>
<View style={styles.quickButtons}> <View style={styles.quickButtons}>
{[50, 60, 70, 80, 90].map((weight) => ( {[50, 60, 70, 80, 90].map((weight) => (
<TouchableOpacity <TouchableOpacity
@@ -358,7 +360,7 @@ export default function WeightRecordsPage() {
styles.quickButtonText, styles.quickButtonText,
inputWeight === weight.toString() && styles.quickButtonTextSelected inputWeight === weight.toString() && styles.quickButtonTextSelected
]}> ]}>
{weight}kg {weight}{t('weightRecords.modal.unit')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@@ -386,7 +388,7 @@ export default function WeightRecordsPage() {
onPress={handleWeightSave} onPress={handleWeightSave}
disabled={!inputWeight.trim()} disabled={!inputWeight.trim()}
> >
<Text style={styles.saveButtonText}></Text> <Text style={styles.saveButtonText}>{t('weightRecords.modal.confirm')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -461,7 +463,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
statValue: { statValue: {
fontSize: 16, fontSize: 14,
fontWeight: '800', fontWeight: '800',
color: '#192126', color: '#192126',
marginBottom: 4, marginBottom: 4,
@@ -475,6 +477,7 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
color: '#687076', color: '#687076',
marginRight: 4, marginRight: 4,
textAlign: 'center'
}, },
editIcon: { editIcon: {
padding: 2, padding: 2,

View File

@@ -16,6 +16,7 @@ import {
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal'; import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail'; import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
import { import {
@@ -233,23 +234,23 @@ function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
}; };
} }
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) { function getIntensityBadge(t: (key: string, options?: any) => string, totalCalories?: number, durationInSeconds?: number): { label: string; color: string; background: string } {
if (!totalCalories || !durationInSeconds) { if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' }; return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
} }
const minutes = Math.max(durationInSeconds / 60, 1); const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes; const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) { if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' }; return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
} }
if (caloriesPerMinute >= 5) { if (caloriesPerMinute >= 5) {
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' }; return { label: t('workoutHistory.intensity.medium'), color: '#0EAF71', background: '#E4F6EF' };
} }
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' }; return { label: t('workoutHistory.intensity.low'), color: '#5966FF', background: '#E7EBFF' };
} }
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] { function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
@@ -265,13 +266,14 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
return Object.keys(grouped) return Object.keys(grouped)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf()) .sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
.map((dateKey) => ({ .map((dateKey) => ({
title: dayjs(dateKey).format('M月D日'), title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
data: grouped[dateKey] data: grouped[dateKey]
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()), .sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
})); }));
} }
export default function WorkoutHistoryScreen() { export default function WorkoutHistoryScreen() {
const { t } = useI18n();
const [sections, setSections] = useState<WorkoutSection[]>([]); const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -284,7 +286,7 @@ export default function WorkoutHistoryScreen() {
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null); const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null); const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop();
const loadHistory = useCallback(async () => { const loadHistory = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -302,7 +304,7 @@ export default function WorkoutHistoryScreen() {
if (!hasPermission) { if (!hasPermission) {
setSections([]); setSections([]);
setError('尚未授予健康数据权限'); setError(t('workoutHistory.error.permissionDenied'));
setMonthlyStats(null); setMonthlyStats(null);
return; return;
} }
@@ -315,8 +317,8 @@ export default function WorkoutHistoryScreen() {
setMonthlyStats(computeMonthlyStats(filteredWorkouts)); setMonthlyStats(computeMonthlyStats(filteredWorkouts));
setSections(groupWorkouts(filteredWorkouts)); setSections(groupWorkouts(filteredWorkouts));
} catch (err) { } catch (err) {
console.error('加载锻炼历史失败:', err); console.error('Failed to load workout history:', err);
setError('加载锻炼记录失败,请稍后再试'); setError(t('workoutHistory.error.loadFailed'));
setSections([]); setSections([]);
setMonthlyStats(null); setMonthlyStats(null);
} finally { } finally {
@@ -350,9 +352,9 @@ export default function WorkoutHistoryScreen() {
? dayjs(monthlyStats.snapshotDate).format('M月D日') ? dayjs(monthlyStats.snapshotDate).format('M月D日')
: dayjs().format('M月D日'); : dayjs().format('M月D日');
const overviewText = monthlyStats const overviewText = monthlyStats
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}` ? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
: '本月还没有锻炼记录,动起来收集第一条吧!'; : t('workoutHistory.monthlyStats.overviewEmpty');
const periodText = `统计周期1日 - ${monthEndDay}日(本月)`; const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay });
const maxDuration = statsItems[0]?.duration || 1; const maxDuration = statsItems[0]?.duration || 1;
return ( return (
@@ -369,7 +371,7 @@ export default function WorkoutHistoryScreen() {
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.monthlyStatsCard} style={styles.monthlyStatsCard}
> >
<Text style={styles.statSectionLabel}></Text> <Text style={styles.statSectionLabel}>{t('workoutHistory.monthlyStats.title')}</Text>
<Text style={styles.statPeriodText}>{periodText}</Text> <Text style={styles.statPeriodText}>{periodText}</Text>
<Text style={styles.statDescription}>{overviewText}</Text> <Text style={styles.statDescription}>{overviewText}</Text>
@@ -403,7 +405,7 @@ export default function WorkoutHistoryScreen() {
) : ( ) : (
<View style={styles.statEmptyState}> <View style={styles.statEmptyState}>
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" /> <MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
<Text style={styles.statEmptyText}></Text> <Text style={styles.statEmptyText}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
</View> </View>
)} )}
</LinearGradient> </LinearGradient>
@@ -416,8 +418,8 @@ export default function WorkoutHistoryScreen() {
const emptyComponent = useMemo(() => ( const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" /> <MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
<Text style={styles.emptySubText}></Text> <Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
</View> </View>
), []); ), []);
@@ -453,7 +455,7 @@ export default function WorkoutHistoryScreen() {
} }
const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType); const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
return `这是你${workoutDate.format('M月')}的第 ${index + 1}${activityLabel}`; return t('workoutHistory.monthOccurrence', { month: workoutDate.format('M月'), index: index + 1, activity: activityLabel });
}, [sections]); }, [sections]);
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => { const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
@@ -463,16 +465,16 @@ export default function WorkoutHistoryScreen() {
const metrics = await getWorkoutDetailMetrics(workout); const metrics = await getWorkoutDetailMetrics(workout);
setDetailMetrics(metrics); setDetailMetrics(metrics);
} catch (err) { } catch (err) {
console.error('加载锻炼详情失败:', err); console.error('Failed to load workout details:', err);
setDetailMetrics(null); setDetailMetrics(null);
setDetailError('加载锻炼详情失败,请稍后再试'); setDetailError(t('workoutHistory.error.detailLoadFailed'));
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}, []); }, []);
const handleWorkoutPress = useCallback((workout: WorkoutData) => { const handleWorkoutPress = useCallback((workout: WorkoutData) => {
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0); const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
setSelectedIntensity(intensity); setSelectedIntensity(intensity);
setSelectedWorkout(workout); setSelectedWorkout(workout);
setDetailMetrics(null); setDetailMetrics(null);
@@ -495,7 +497,7 @@ export default function WorkoutHistoryScreen() {
const renderItem = useCallback(({ item }: { item: WorkoutData }) => { const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0); const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1); const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0); const intensity = getIntensityBadge(t, item.totalEnergyBurned, item.duration || 0);
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex'; const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
const time = dayjs(item.startDate || item.endDate).format('HH:mm'); const time = dayjs(item.startDate || item.endDate).format('HH:mm');
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType); const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
@@ -512,12 +514,12 @@ export default function WorkoutHistoryScreen() {
<View style={styles.cardContent}> <View style={styles.cardContent}>
<View style={styles.cardTitleRow}> <View style={styles.cardTitleRow}>
<Text style={styles.cardTitle}>{calories} · {minutes}</Text> <Text style={styles.cardTitle}>{t('workoutHistory.historyCard.calories', { calories, minutes })}</Text>
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}> <View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text> <Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
</View> </View>
</View> </View>
<Text style={styles.cardSubtitle}>{activityLabel}{time}</Text> <Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
</View> </View>
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */} {/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
@@ -535,11 +537,11 @@ export default function WorkoutHistoryScreen() {
colors={["#F3F5FF", "#FFFFFF"]} colors={["#F3F5FF", "#FFFFFF"]}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} /> <HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
{isLoading ? ( {isLoading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" /> <ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
</View> </View>
) : ( ) : (
<SectionList <SectionList
@@ -556,7 +558,7 @@ export default function WorkoutHistoryScreen() {
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" /> <MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text> <Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}> <TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
<Text style={styles.retryText}></Text> <Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : emptyComponent} ) : emptyComponent}

View File

@@ -1,4 +1,5 @@
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { useI18n } from '@/hooks/useI18n';
import { getMonthDays, getMonthTitle, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
autoScrollToSelected = true, autoScrollToSelected = true,
showCalendarIcon = true, showCalendarIcon = true,
}) => { }) => {
const { t, i18n } = useI18n();
// 内部状态管理 // 内部状态管理
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth()); const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份 const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const isGlassAvailable = isLiquidGlassAvailable(); const isGlassAvailable = isLiquidGlassAvailable();
// 获取日期数据 // 获取日期数据
const days = getMonthDaysZh(currentMonth); const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth); const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
// 判断当前选中的日期是否是今天 // 判断当前选中的日期是否是今天
const isSelectedDateToday = () => { const isSelectedDateToday = () => {
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
setCurrentMonth(selectedMonth); setCurrentMonth(selectedMonth);
// 计算选中日期在新月份中的索引 // 计算选中日期在新月份中的索引
const newMonthDays = getMonthDaysZh(selectedMonth); const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
const selectedDay = selectedMonth.date(); const selectedDay = selectedMonth.date();
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay); const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const handleGoToday = () => { const handleGoToday = () => {
const today = dayjs(); const today = dayjs();
setCurrentMonth(today); setCurrentMonth(today);
const todayDays = getMonthDaysZh(today); const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date()); const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
if (newSelectedIndex !== -1) { if (newSelectedIndex !== -1) {
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
tintColor="rgba(124, 58, 237, 0.08)" tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true} isInteractive={true}
> >
<Text style={styles.todayButtonText}></Text> <Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}> <View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text> <Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'} display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()} minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined} maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})} {...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => { onChange={(event, date) => {
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
if (date) setPickerDate(date); if (date) setPickerDate(date);
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && ( {Platform.OS === 'ios' && (
<View style={styles.modalActions}> <View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}> <TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => { <TouchableOpacity onPress={() => {
onConfirmDate(pickerDate); onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}> }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'} display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()} minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined} maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})} {...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => { onChange={(event, date) => {
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
if (date) setPickerDate(date); if (date) setPickerDate(date);
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && ( {Platform.OS === 'ios' && (
<View style={styles.modalActions}> <View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}> <TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => { <TouchableOpacity onPress={() => {
onConfirmDate(pickerDate); onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}> }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice'; import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}; };
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} 小时`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) { export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
console.log('unit', unit); const { t } = useI18n();
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} ${t('challengeDetail.ranking.hour')}`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit); const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit); const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
const progressLabel = reportedLabel && targetLabel const progressLabel = reportedLabel && targetLabel
? `今日 ${reportedLabel} / ${targetLabel}` ? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
: reportedLabel : reportedLabel
? `今日 ${reportedLabel}` ? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
: targetLabel : targetLabel
? `今日目标 ${targetLabel}` ? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
: undefined; : undefined;
return ( return (

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
@@ -25,6 +26,8 @@ interface MedicationPhotoGuideModalProps {
* 展示如何正确拍摄药品照片的说明和示例 * 展示如何正确拍摄药品照片的说明和示例
*/ */
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) { export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
const { t } = useI18n();
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -48,8 +51,12 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
> >
{/* 标题部分 */} {/* 标题部分 */}
<View style={styles.guideHeader}> <View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}></Text> <Text style={styles.guideStepBadge}>
<Text style={styles.guideTitle}></Text> {t('medications.aiCamera.guideModal.badge')}
</Text>
<Text style={styles.guideTitle}>
{t('medications.aiCamera.guideModal.title')}
</Text>
</View> </View>
{/* 示例图片 */} {/* 示例图片 */}
@@ -99,10 +106,10 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
{/* 说明文字 */} {/* 说明文字 */}
<View style={styles.guideDescription}> <View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}> <Text style={styles.guideDescriptionText}>
\\ {t('medications.aiCamera.guideModal.description1')}
</Text> </Text>
<Text style={styles.guideDescriptionText}> <Text style={styles.guideDescriptionText}>
线 {t('medications.aiCamera.guideModal.description2')}
</Text> </Text>
</View> </View>
@@ -124,7 +131,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient} style={styles.guideConfirmButtonGradient}
> >
<Text style={styles.guideConfirmButtonText}></Text> <Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient> </LinearGradient>
</GlassView> </GlassView>
) : ( ) : (
@@ -135,7 +144,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient} style={styles.guideConfirmButtonGradient}
> >
<Text style={styles.guideConfirmButtonText}></Text> <Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient> </LinearGradient>
</View> </View>
)} )}

View File

@@ -11,6 +11,7 @@ import {
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// 睡眠详情数据类型 // 睡眠详情数据类型
export type SleepDetailData = { export type SleepDetailData = {
@@ -41,15 +42,22 @@ const SleepGradeCard = ({
range: string; range: string;
isActive?: boolean; isActive?: boolean;
}) => { }) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const getGradeColor = (grade: string) => { const getGradeColor = (grade: string) => {
switch (grade) { switch (grade) {
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' }; case t('sleepDetail.sleepGrades.low'):
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' }; case t('sleepDetail.sleepGrades.poor'):
case '良好': return { bg: '#D1FAE5', text: '#065F46' }; return { bg: '#FECACA', text: '#DC2626' };
case '优秀': return { bg: '#FEF3C7', text: '#92400E' }; case t('sleepDetail.sleepGrades.normal'):
case t('sleepDetail.sleepGrades.fair'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.good'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.excellent'):
return { bg: '#FEF3C7', text: '#92400E' };
default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary }; default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary };
} }
}; };
@@ -97,6 +105,7 @@ export const InfoModal = ({
type: 'sleep-time' | 'sleep-quality'; type: 'sleep-time' | 'sleep-quality';
sleepData: SleepDetailData; sleepData: SleepDetailData;
}) => { }) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0]; const slideAnim = useState(new Animated.Value(0))[0];
@@ -153,26 +162,26 @@ export const InfoModal = ({
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94% const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
const sleepTimeGrades = [ const sleepTimeGrades = [
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 }, { icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.low'), range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 }, { icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.normal'), range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 }, { icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 }, { icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
]; ];
const sleepQualityGrades = [ const sleepQualityGrades = [
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 }, { icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.poor'), range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 }, { icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.fair'), range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 }, { icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 }, { icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
]; ];
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades; const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
const getDescription = () => { const getDescription = () => {
if (type === 'sleep-time') { if (type === 'sleep-time') {
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。'; return t('sleepDetail.sleepTimeDescription');
} else { } else {
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长还包括睡眠的连续性和各睡眠阶段的平衡。'; return t('sleepDetail.sleepQualityDescription');
} }
}; };

View File

@@ -7,18 +7,24 @@ import React, { useMemo } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg'; import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import { StyleProp, ViewStyle } from 'react-native';
export type SleepStageTimelineProps = { export type SleepStageTimelineProps = {
sleepSamples: SleepSample[]; sleepSamples: SleepSample[];
bedtime: string; bedtime: string;
wakeupTime: string; wakeupTime: string;
onInfoPress?: () => void; onInfoPress?: () => void;
hideHeader?: boolean;
style?: StyleProp<ViewStyle>;
}; };
export const SleepStageTimeline = ({ export const SleepStageTimeline = ({
sleepSamples, sleepSamples,
bedtime, bedtime,
wakeupTime, wakeupTime,
onInfoPress onInfoPress,
hideHeader = false,
style
}: SleepStageTimelineProps) => { }: SleepStageTimelineProps) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -130,15 +136,17 @@ export const SleepStageTimeline = ({
// 如果没有数据,显示空状态 // 如果没有数据,显示空状态
if (timelineData.length === 0) { if (timelineData.length === 0) {
return ( return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}> <View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
<View style={styles.header}> {!hideHeader && (
<Text style={[styles.title, { color: colorTokens.text }]}></Text> <View style={styles.header}>
{onInfoPress && ( <Text style={[styles.title, { color: colorTokens.text }]}></Text>
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}> {onInfoPress && (
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} /> <TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
</TouchableOpacity> <Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
)} </TouchableOpacity>
</View> )}
</View>
)}
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}> <Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
@@ -149,16 +157,18 @@ export const SleepStageTimeline = ({
} }
return ( return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}> <View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
{/* 标题栏 */} {/* 标题栏 */}
<View style={styles.header}> {!hideHeader && (
<Text style={[styles.title, { color: colorTokens.text }]}></Text> <View style={styles.header}>
{onInfoPress && ( <Text style={[styles.title, { color: colorTokens.text }]}></Text>
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}> {onInfoPress && (
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} /> <TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
</TouchableOpacity> <Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
)} </TouchableOpacity>
</View> )}
</View>
)}
{/* 睡眠时间范围 */} {/* 睡眠时间范围 */}
<View style={styles.timeRange}> <View style={styles.timeRange}>

View File

@@ -13,6 +13,7 @@ import {
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// Sleep Stages Info Modal 组件 // Sleep Stages Info Modal 组件
export const SleepStagesInfoModal = ({ export const SleepStagesInfoModal = ({
@@ -22,6 +23,7 @@ export const SleepStagesInfoModal = ({
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
}) => { }) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0]; const slideAnim = useState(new Animated.Value(0))[0];
@@ -82,7 +84,7 @@ export const SleepStagesInfoModal = ({
<View style={styles.sleepStagesModalHeader}> <View style={styles.sleepStagesModalHeader}>
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}> <Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStagesInfo.title')}
</Text> </Text>
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}> <TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
<Ionicons name="close" size={24} color={colorTokens.textSecondary} /> <Ionicons name="close" size={24} color={colorTokens.textSecondary} />
@@ -97,7 +99,7 @@ export const SleepStagesInfoModal = ({
scrollEnabled={true} scrollEnabled={true}
> >
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.description')}
</Text> </Text>
{/* 清醒时间 */} {/* 清醒时间 */}
@@ -105,11 +107,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.awake.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.awake.description')}
</Text> </Text>
</View> </View>
@@ -118,11 +120,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.rem.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.rem.description')}
</Text> </Text>
</View> </View>
@@ -131,11 +133,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.core.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.core.description')}
</Text> </Text>
</View> </View>
@@ -144,11 +146,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.deep.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.deep.description')}
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -1,5 +1,6 @@
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { WeightHistoryItem } from '@/store/userSlice'; import { WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -20,6 +21,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
onDelete, onDelete,
weightChange = 0 weightChange = 0
}) => { }) => {
const { t } = useI18n();
const swipeableRef = useRef<Swipeable>(null); const swipeableRef = useRef<Swipeable>(null);
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light']; const themeColors = Colors[colorScheme ?? 'light'];
@@ -27,15 +29,15 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
// 处理删除操作 // 处理删除操作
const handleDelete = () => { const handleDelete = () => {
Alert.alert( Alert.alert(
'确认删除', t('weightRecords.card.deleteConfirmTitle'),
`确定要删除这条体重记录吗?此操作无法撤销。`, t('weightRecords.card.deleteConfirmMessage'),
[ [
{ {
text: '取消', text: t('weightRecords.card.cancelButton'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: '删除', text: t('weightRecords.card.deleteButton'),
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
const recordId = record.id || record.createdAt; const recordId = record.id || record.createdAt;
@@ -56,7 +58,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="trash" size={20} color="#FFFFFF" /> <Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text> <Text style={styles.deleteButtonText}>{t('weightRecords.card.deleteButton')}</Text>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
@@ -73,7 +75,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
> >
<View style={styles.recordHeader}> <View style={styles.recordHeader}>
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}> <Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
{dayjs(record.createdAt).format('MM月DD日 HH:mm')} {dayjs(record.createdAt).format('MM[月]DD[日] HH:mm')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.recordEditButton} style={styles.recordEditButton}
@@ -83,8 +85,8 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.recordContent}> <View style={styles.recordContent}>
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}></Text> <Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.card.weightLabel')}</Text>
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}kg</Text> <Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}{t('weightRecords.modal.unit')}</Text>
{Math.abs(weightChange) > 0 && ( {Math.abs(weightChange) > 0 && (
<View style={[ <View style={[
styles.weightChangeTag, styles.weightChangeTag,

View File

@@ -15,6 +15,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { useI18n } from '@/hooks/useI18n';
import { import {
HeartRateZoneStat, HeartRateZoneStat,
WorkoutDetailMetrics, WorkoutDetailMetrics,
@@ -59,6 +60,7 @@ export function WorkoutDetailModal({
onRetry, onRetry,
errorMessage, errorMessage,
}: WorkoutDetailModalProps) { }: WorkoutDetailModalProps) {
const { t } = useI18n();
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current; const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
const [isMounted, setIsMounted] = useState(visible); const [isMounted, setIsMounted] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false); const [showIntensityInfo, setShowIntensityInfo] = useState(false);
@@ -229,26 +231,26 @@ export function WorkoutDetailModal({
{loading ? ( {loading ? (
<View style={styles.loadingBlock}> <View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" /> <ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>...</Text> <Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View> </View>
) : metrics ? ( ) : metrics ? (
<> <>
<View style={styles.metricsRow}> <View style={styles.metricsRow}>
<View style={styles.metricItem}> <View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text> <Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text> <Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View> </View>
<View style={styles.metricItem}> <View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text> <Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}> <Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'} {metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.metricsRow}> <View style={styles.metricsRow}>
<View style={styles.metricItem}> <View style={styles.metricItem}>
<View style={styles.metricTitleRow}> <View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}></Text> <Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => setShowIntensityInfo(true)} onPress={() => setShowIntensityInfo(true)}
style={styles.metricInfoButton} style={styles.metricInfoButton}
@@ -262,9 +264,9 @@ export function WorkoutDetailModal({
</Text> </Text>
</View> </View>
<View style={styles.metricItem}> <View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text> <Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}> <Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'} {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text> </Text>
</View> </View>
</View> </View>
@@ -275,11 +277,11 @@ export function WorkoutDetailModal({
) : ( ) : (
<View style={styles.errorBlock}> <View style={styles.errorBlock}>
<Text style={styles.errorText}> <Text style={styles.errorText}>
{errorMessage || '未能获取到完整的锻炼详情'} {errorMessage || t('workoutDetail.errors.loadFailed')}
</Text> </Text>
{onRetry ? ( {onRetry ? (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}> <TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryButtonText}></Text> <Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
</View> </View>
@@ -288,7 +290,7 @@ export function WorkoutDetailModal({
<View style={styles.section}> <View style={styles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View> </View>
{loading ? ( {loading ? (
@@ -299,21 +301,21 @@ export function WorkoutDetailModal({
<> <>
<View style={styles.heartRateSummaryRow}> <View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}> <View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'} {metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text> </Text>
</View> </View>
<View style={styles.heartRateStat}> <View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'} {metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text> </Text>
</View> </View>
<View style={styles.heartRateStat}> <View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'} {metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text> </Text>
</View> </View>
</View> </View>
@@ -336,7 +338,7 @@ export function WorkoutDetailModal({
width={Dimensions.get('window').width - 72} width={Dimensions.get('window').width - 72}
height={220} height={220}
fromZero={false} fromZero={false}
yAxisSuffix="次/分" yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false} withInnerLines={false}
bezier bezier
chartConfig={{ chartConfig={{
@@ -360,20 +362,20 @@ export function WorkoutDetailModal({
) : ( ) : (
<View style={styles.chartEmpty}> <View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" /> <MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>线</Text> <Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View> </View>
) )
) : ( ) : (
<View style={styles.chartEmpty}> <View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" /> <MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}></Text> <Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View> </View>
)} )}
</> </>
) : ( ) : (
<View style={styles.sectionError}> <View style={styles.sectionError}>
<Text style={styles.errorTextSmall}> <Text style={styles.errorTextSmall}>
{errorMessage || '未获取到心率数据'} {errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text> </Text>
</View> </View>
)} )}
@@ -381,7 +383,7 @@ export function WorkoutDetailModal({
<View style={styles.section}> <View style={styles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View> </View>
{loading ? ( {loading ? (
@@ -391,7 +393,7 @@ export function WorkoutDetailModal({
) : metrics ? ( ) : metrics ? (
metrics.heartRateZones.map(renderHeartRateZone) metrics.heartRateZones.map(renderHeartRateZone)
) : ( ) : (
<Text style={styles.errorTextSmall}></Text> <Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)} )}
</View> </View>
@@ -410,36 +412,36 @@ export function WorkoutDetailModal({
<TouchableWithoutFeedback onPress={() => { }}> <TouchableWithoutFeedback onPress={() => { }}>
<View style={styles.intensityInfoSheet}> <View style={styles.intensityInfoSheet}>
<View style={styles.intensityHandle} /> <View style={styles.intensityHandle} />
<Text style={styles.intensityInfoTitle}></Text> <Text style={styles.intensityInfoTitle}>{t('workoutDetail.intensityInfo.title')}</Text>
<Text style={styles.intensityInfoText}> <Text style={styles.intensityInfoText}>
MET/· {t('workoutDetail.intensityInfo.description1')}
</Text> </Text>
<Text style={styles.intensityInfoText}> <Text style={styles.intensityInfoText}>
MET 便 {t('workoutDetail.intensityInfo.description2')}
</Text> </Text>
<Text style={styles.intensityInfoText}> <Text style={styles.intensityInfoText}>
3 km/h 2 METs 2 {t('workoutDetail.intensityInfo.description3')}
</Text> </Text>
<Text style={styles.intensityInfoText}> <Text style={styles.intensityInfoText}>
METs 使70 {t('workoutDetail.intensityInfo.description4')}
</Text> </Text>
<View style={styles.intensityFormula}> <View style={styles.intensityFormula}>
<Text style={styles.intensityFormulaLabel}></Text> <Text style={styles.intensityFormulaLabel}>{t('workoutDetail.intensityInfo.formula.title')}</Text>
<Text style={styles.intensityFormulaValue}>METs = / ÷ 1 /</Text> <Text style={styles.intensityFormulaValue}>{t('workoutDetail.intensityInfo.formula.value')}</Text>
</View> </View>
<View style={styles.intensityLegend}> <View style={styles.intensityLegend}>
<View style={styles.intensityLegendRow}> <View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'< 3'}</Text> <Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.low')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}></Text> <Text style={[styles.intensityLegendLabel, styles.intensityLow]}>{t('workoutDetail.intensityInfo.legend.lowLabel')}</Text>
</View> </View>
<View style={styles.intensityLegendRow}> <View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>3 - 6</Text> <Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.medium')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}></Text> <Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>{t('workoutDetail.intensityInfo.legend.mediumLabel')}</Text>
</View> </View>
<View style={styles.intensityLegendRow}> <View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text> <Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.high')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}></Text> <Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>{t('workoutDetail.intensityInfo.legend.highLabel')}</Text>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -5,6 +5,7 @@ import { Alert } from 'react-native';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { STORAGE_KEYS, api } from '@/services/api'; import { STORAGE_KEYS, api } from '@/services/api';
import { logout as logoutAction } from '@/store/userSlice'; import { logout as logoutAction } from '@/store/userSlice';
@@ -21,6 +22,7 @@ export function useAuthGuard() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const currentPath = usePathname(); const currentPath = usePathname();
const user = useAppSelector(state => state.user); const user = useAppSelector(state => state.user);
const { t } = useI18n();
// 判断登录状态:优先使用 token因为 token 是登录的根本凭证 // 判断登录状态:优先使用 token因为 token 是登录的根本凭证
// profile.id 可能在初始化时还未加载,但 token 已经从 AsyncStorage 恢复 // profile.id 可能在初始化时还未加载,但 token 已经从 AsyncStorage 恢复
@@ -74,28 +76,28 @@ export function useAuthGuard() {
router.push('/auth/login'); router.push('/auth/login');
} catch (error) { } catch (error) {
console.error('退出登录失败:', error); console.error('退出登录失败:', error);
Alert.alert('错误', '退出登录失败,请稍后重试'); Alert.alert(t('authGuard.logout.error'), t('authGuard.logout.errorMessage'));
} }
}, [dispatch, router]); }, [dispatch, router, t]);
// 带确认对话框的退出登录 // 带确认对话框的退出登录
const confirmLogout = useCallback(() => { const confirmLogout = useCallback(() => {
Alert.alert( Alert.alert(
'确认退出', t('authGuard.confirmLogout.title'),
'确定要退出当前账号吗?', t('authGuard.confirmLogout.message'),
[ [
{ {
text: '取消', text: t('authGuard.confirmLogout.cancelButton'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: '确定', text: t('authGuard.confirmLogout.confirmButton'),
style: 'default', style: 'default',
onPress: handleLogout, onPress: handleLogout,
}, },
] ]
); );
}, [handleLogout]); }, [handleLogout, t]);
// 注销账号功能 // 注销账号功能
const handleDeleteAccount = useCallback(async () => { const handleDeleteAccount = useCallback(async () => {
@@ -109,38 +111,38 @@ export function useAuthGuard() {
// 执行退出登录逻辑 // 执行退出登录逻辑
await dispatch(logoutAction()).unwrap(); await dispatch(logoutAction()).unwrap();
Alert.alert('账号已注销', '您的账号已成功注销', [ Alert.alert(t('authGuard.deleteAccount.successTitle'), t('authGuard.deleteAccount.successMessage'), [
{ {
text: '确定', text: t('authGuard.deleteAccount.confirmButton'),
onPress: () => router.push('/auth/login'), onPress: () => router.push('/auth/login'),
}, },
]); ]);
} catch (error: any) { } catch (error: any) {
console.error('注销账号失败:', error); console.error('注销账号失败:', error);
const message = error?.message || '注销失败,请稍后重试'; const message = error?.message || t('authGuard.deleteAccount.errorMessage');
Alert.alert('注销失败', message); Alert.alert(t('authGuard.deleteAccount.errorTitle'), message);
} }
}, [dispatch, router]); }, [dispatch, router, t]);
// 带确认对话框的注销账号 // 带确认对话框的注销账号
const confirmDeleteAccount = useCallback(() => { const confirmDeleteAccount = useCallback(() => {
Alert.alert( Alert.alert(
'确认注销账号', t('authGuard.confirmDeleteAccount.title'),
'此操作不可恢复,将删除您的账号及相关数据。确定继续吗?', t('authGuard.confirmDeleteAccount.message'),
[ [
{ {
text: '取消', text: t('authGuard.confirmDeleteAccount.cancelButton'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: '确认注销', text: t('authGuard.confirmDeleteAccount.confirmButton'),
style: 'destructive', style: 'destructive',
onPress: handleDeleteAccount, onPress: handleDeleteAccount,
}, },
], ],
{ cancelable: true } { cancelable: true }
); );
}, [handleDeleteAccount]); }, [handleDeleteAccount, t]);
return { return {
isLoggedIn, isLoggedIn,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
// 默认使用中文,可以通过参数切换
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
/** /**
@@ -61,9 +63,54 @@ export function getMonthDaysZh(date: Dayjs = dayjs()): MonthDay[] {
}); });
} }
/** 获取今天在当月的索引0 基) */ /** 获取"今天"在当月的索引0 基) */
export function getTodayIndexInMonth(date: Dayjs = dayjs()): number { export function getTodayIndexInMonth(date: Dayjs = dayjs()): number {
return date.date() - 1; return date.date() - 1;
} }
/** 获取某月的所有日期(多语言版本) */
export function getMonthDays(date: Dayjs = dayjs(), locale: 'zh' | 'en' = 'zh'): MonthDay[] {
const year = date.year();
const monthIndex = date.month();
const daysInMonth = date.daysInMonth();
// 根据语言选择星期显示
const weekDays = locale === 'zh'
? ['日', '一', '二', '三', '四', '五', '六']
: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const today = dayjs();
return Array.from({ length: daysInMonth }, (_, i) => {
const d = dayjs(new Date(year, monthIndex, i + 1));
const isToday = d.isSame(today, 'day');
return {
weekdayZh: weekDays[d.day()], // 保持原有字段名兼容性
dayAbbr: weekDays[d.day()],
dayOfMonth: i + 1,
date: d,
isToday,
};
});
}
/** 获取本地化的月份日期格式 */
export function getLocalizedDateFormat(date: Dayjs, locale: 'zh' | 'en' = 'zh'): string {
if (locale === 'zh') {
return date.format('M月D日');
} else {
return date.format('MMM D');
}
}
/** 获取本地化的月份标题 */
export function getMonthTitle(date: Dayjs = dayjs(), locale: 'zh' | 'en' = 'zh'): string {
if (locale === 'zh') {
return date.format('YY年M月');
} else {
return date.format('MMM YYYY');
}
}