feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块 - 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本 - 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示 - 完善登录页、注销流程及权限申请弹窗的双语提示信息 - 优化部分页面的 UI 细节与字体样式以适配多语言显示
This commit is contained in:
@@ -602,14 +602,14 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 26,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
fontFamily: 'AliRegular'
|
||||
@@ -619,8 +619,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
joinButtonGlass: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
@@ -629,7 +629,7 @@ const styles = StyleSheet.create({
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
@@ -639,8 +639,8 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
createButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { fetchMyProfile, login } from '@/store/userSlice';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function LoginScreen() {
|
||||
const color = Colors[scheme];
|
||||
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useI18n();
|
||||
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
|
||||
|
||||
// 背景动效:轻微平移/旋转与呼吸动画
|
||||
@@ -79,12 +81,12 @@ export default function LoginScreen() {
|
||||
const guardAgreement = useCallback((action: () => void) => {
|
||||
if (!hasAgreed) {
|
||||
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: () => {
|
||||
setHasAgreed(true);
|
||||
setTimeout(() => action(), 0);
|
||||
@@ -96,7 +98,7 @@ export default function LoginScreen() {
|
||||
return;
|
||||
}
|
||||
action();
|
||||
}, [hasAgreed]);
|
||||
}, [hasAgreed, t]);
|
||||
|
||||
const onAppleLogin = useCallback(async () => {
|
||||
if (!appleAvailable) return;
|
||||
@@ -110,7 +112,7 @@ export default function LoginScreen() {
|
||||
});
|
||||
const identityToken = (credential as any)?.identityToken;
|
||||
if (!identityToken || typeof identityToken !== 'string') {
|
||||
throw new Error('未获取到 Apple 身份令牌');
|
||||
throw new Error(t('login.errors.appleIdentityTokenMissing'));
|
||||
}
|
||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||
|
||||
@@ -118,7 +120,7 @@ export default function LoginScreen() {
|
||||
await dispatch(fetchMyProfile())
|
||||
|
||||
Toast.show({
|
||||
text1: '登录成功',
|
||||
text1: t('login.success.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
// 登录成功后处理重定向
|
||||
@@ -145,12 +147,12 @@ export default function LoginScreen() {
|
||||
console.log('err.code', err.code);
|
||||
|
||||
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
|
||||
const message = err?.message || '登录失败,请稍后再试';
|
||||
Alert.alert('登录失败', message);
|
||||
const message = err?.message || t('login.errors.loginFailed');
|
||||
Alert.alert(t('login.errors.loginFailedTitle'), message);
|
||||
} finally {
|
||||
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'} />
|
||||
</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>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerWrap}>
|
||||
<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>
|
||||
|
||||
{/* Apple 登录 */}
|
||||
@@ -276,12 +278,12 @@ export default function LoginScreen() {
|
||||
color="#FFFFFF"
|
||||
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 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
@@ -294,12 +296,12 @@ export default function LoginScreen() {
|
||||
color="#FFFFFF"
|
||||
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 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -319,13 +321,13 @@ export default function LoginScreen() {
|
||||
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
|
||||
</View>
|
||||
</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)}>
|
||||
<Text style={[styles.link, { color: color.primary }]}>《隐私政策》</Text>
|
||||
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
|
||||
</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)}>
|
||||
<Text style={[styles.link, { color: color.primary }]}>《用户协议》</Text>
|
||||
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
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 { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -24,6 +25,7 @@ type BasalMetabolismData = {
|
||||
};
|
||||
|
||||
export default function BasalMetabolismDetailScreen() {
|
||||
const { t, i18n } = useI18n();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const userAge = useAppSelector(selectUserAge);
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
@@ -140,9 +142,9 @@ export default function BasalMetabolismDetailScreen() {
|
||||
|
||||
// 获取当前选中日期
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
const days = getMonthDaysZh();
|
||||
const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}, [selectedIndex]);
|
||||
}, [selectedIndex, i18n.language]);
|
||||
|
||||
|
||||
// 计算BMR范围
|
||||
@@ -203,7 +205,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
setSelectedIndex(index);
|
||||
|
||||
// 获取选中日期
|
||||
const days = getMonthDaysZh();
|
||||
const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
|
||||
const selectedDate = days[index]?.date?.toDate();
|
||||
|
||||
if (selectedDate) {
|
||||
@@ -247,7 +249,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
@@ -280,7 +282,8 @@ export default function BasalMetabolismDetailScreen() {
|
||||
// 显示周数
|
||||
const weekOfYear = dayjs(item.date).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:
|
||||
return dayjs(item.date).format('MM-DD');
|
||||
}
|
||||
@@ -319,7 +322,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
|
||||
{/* 头部导航 */}
|
||||
<HeaderBar
|
||||
title="基础代谢"
|
||||
title={t('basalMetabolismDetail.title')}
|
||||
transparent
|
||||
right={
|
||||
<TouchableOpacity
|
||||
@@ -355,7 +358,9 @@ export default function BasalMetabolismDetailScreen() {
|
||||
{/* 当前日期基础代谢显示 */}
|
||||
<View style={styles.currentDataCard}>
|
||||
<Text style={styles.currentDataTitle}>
|
||||
{dayjs(currentSelectedDate).format('M月D日')} 基础代谢
|
||||
{t('basalMetabolismDetail.currentData.title', {
|
||||
date: getLocalizedDateFormat(dayjs(currentSelectedDate), i18n.language as 'zh' | 'en')
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.currentValueContainer}>
|
||||
<Text style={styles.currentValue}>
|
||||
@@ -366,21 +371,24 @@ export default function BasalMetabolismDetailScreen() {
|
||||
if (selectedDateData?.value) {
|
||||
return Math.round(selectedDateData.value).toString();
|
||||
}
|
||||
return '--';
|
||||
return t('basalMetabolismDetail.currentData.noData');
|
||||
})()}
|
||||
</Text>
|
||||
<Text style={styles.currentUnit}>千卡</Text>
|
||||
<Text style={styles.currentUnit}>{t('basalMetabolismDetail.currentData.unit')}</Text>
|
||||
</View>
|
||||
{bmrRange && (
|
||||
<Text style={styles.rangeText}>
|
||||
正常范围: {bmrRange.min}-{bmrRange.max} 千卡
|
||||
{t('basalMetabolismDetail.currentData.normalRange', {
|
||||
min: bmrRange.min,
|
||||
max: bmrRange.max
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 基础代谢统计 */}
|
||||
<View style={styles.statsCard}>
|
||||
<Text style={styles.statsTitle}>基础代谢统计</Text>
|
||||
<Text style={styles.statsTitle}>{t('basalMetabolismDetail.stats.title')}</Text>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View style={styles.tabContainer}>
|
||||
@@ -390,7 +398,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
|
||||
按周
|
||||
{t('basalMetabolismDetail.stats.tabs.week')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -399,7 +407,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||
按月
|
||||
{t('basalMetabolismDetail.stats.tabs.month')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -408,28 +416,30 @@ export default function BasalMetabolismDetailScreen() {
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingChart}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
<Text style={styles.loadingText}>{t('basalMetabolismDetail.chart.loadingText')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorChart}>
|
||||
<Text style={styles.errorText}>加载失败: {error}</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{t('basalMetabolismDetail.chart.error.text', { error })}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
// 重新加载数据
|
||||
// {t('basalMetabolismDetail.comments.reloadData')}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchBasalMetabolismData(activeTab).then(data => {
|
||||
setChartData(data);
|
||||
setIsLoading(false);
|
||||
}).catch(err => {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
<Text style={styles.retryText}>{t('basalMetabolismDetail.chart.error.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
|
||||
@@ -441,7 +451,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
width={Dimensions.get('window').width - 80}
|
||||
height={220}
|
||||
yAxisLabel=""
|
||||
yAxisSuffix="千卡"
|
||||
yAxisSuffix={t('basalMetabolismDetail.chart.yAxisSuffix')}
|
||||
chartConfig={{
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundGradientFrom: '#ffffff',
|
||||
@@ -470,7 +480,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyChart}>
|
||||
<Text style={styles.emptyChartText}>暂无数据</Text>
|
||||
<Text style={styles.emptyChartText}>{t('basalMetabolismDetail.chart.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -490,56 +500,66 @@ export default function BasalMetabolismDetailScreen() {
|
||||
style={styles.closeButton}
|
||||
onPress={() => setInfoModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>×</Text>
|
||||
<Text style={styles.closeButtonText}>{t('basalMetabolismDetail.modal.closeButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.modalTitle}>基础代谢</Text>
|
||||
<Text style={styles.modalTitle}>{t('basalMetabolismDetail.modal.title')}</Text>
|
||||
|
||||
{/* 基础代谢定义 */}
|
||||
<Text style={styles.modalDescription}>
|
||||
基础代谢,也称基础代谢率(BMR),是指人体在完全静息状态下维持基本生命功能(心跳、呼吸、体温调节等)所需的最低能量消耗,通常以卡路里为单位。
|
||||
{t('basalMetabolismDetail.modal.description')}
|
||||
</Text>
|
||||
|
||||
{/* 为什么重要 */}
|
||||
<Text style={styles.sectionTitle}>为什么重要?</Text>
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.importance.title')}</Text>
|
||||
<Text style={styles.sectionContent}>
|
||||
基础代谢占总能量消耗的60-75%,是能量平衡的基础。了解您的基础代谢有助于制定科学的营养计划、优化体重管理策略,以及评估代谢健康状态。
|
||||
{t('basalMetabolismDetail.modal.sections.importance.content')}
|
||||
</Text>
|
||||
|
||||
{/* 正常范围 */}
|
||||
<Text style={styles.sectionTitle}>正常范围</Text>
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.normalRange.title')}</Text>
|
||||
<Text style={styles.formulaText}>
|
||||
- 男性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 + 5
|
||||
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.male')}
|
||||
</Text>
|
||||
<Text style={styles.formulaText}>
|
||||
- 女性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 - 161
|
||||
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.female')}
|
||||
</Text>
|
||||
|
||||
{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}>
|
||||
(在公式基础计算值上下浮动15%都属于正常范围)
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.rangeNote')}
|
||||
</Text>
|
||||
<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 style={styles.rangeText}>请完善基本信息以计算您的代谢率</Text>
|
||||
<Text style={styles.rangeText}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.incompleteInfo')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 提高代谢率的策略 */}
|
||||
<Text style={styles.sectionTitle}>提高代谢率的策略</Text>
|
||||
<Text style={styles.strategyText}>科学研究支持以下方法:</Text>
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.strategies.title')}</Text>
|
||||
<Text style={styles.strategyText}>{t('basalMetabolismDetail.modal.sections.strategies.subtitle')}</Text>
|
||||
|
||||
<View style={styles.strategyList}>
|
||||
<Text style={styles.strategyItem}>1.增加肌肉量 (每周2-3次力量训练)</Text>
|
||||
<Text style={styles.strategyItem}>2.高强度间歇训练 (HIIT)</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>
|
||||
{(t('basalMetabolismDetail.modal.sections.strategies.items', { returnObjects: true }) as string[]).map((item: string, index: number) => (
|
||||
<Text key={index} style={styles.strategyItem}>{item}</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
fetchChallengeDetail,
|
||||
@@ -37,6 +38,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useI18n();
|
||||
|
||||
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
||||
@@ -75,12 +77,12 @@ export default function ChallengeLeaderboardScreen() {
|
||||
if (!id) {
|
||||
return (
|
||||
<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={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<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>
|
||||
);
|
||||
@@ -89,10 +91,10 @@ export default function ChallengeLeaderboardScreen() {
|
||||
if (detailStatus === 'loading' && !challenge) {
|
||||
return (
|
||||
<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}>
|
||||
<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>
|
||||
);
|
||||
@@ -131,10 +133,10 @@ export default function ChallengeLeaderboardScreen() {
|
||||
if (!challenge) {
|
||||
return (
|
||||
<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}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
|
||||
{detailError ?? t('challengeDetail.leaderboard.loadFailed')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -146,7 +148,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
|
||||
@@ -178,7 +180,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
{showInitialRankingLoading ? (
|
||||
<View style={styles.rankingLoading}>
|
||||
<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>
|
||||
) : rankingData.length ? (
|
||||
rankingData.map((item, index) => (
|
||||
@@ -196,18 +198,18 @@ export default function ChallengeLeaderboardScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyRanking}>
|
||||
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||
<Text style={styles.emptyRankingText}>{t('challengeDetail.leaderboard.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoadingMore ? (
|
||||
<View style={styles.loadMoreIndicator}>
|
||||
<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>
|
||||
) : null}
|
||||
{rankingLoadMoreStatus === 'failed' ? (
|
||||
<View style={styles.loadMoreIndicator}>
|
||||
<Text style={styles.loadMoreErrorText}>加载更多失败,请下拉刷新重试</Text>
|
||||
<Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -25,6 +25,7 @@ const CIRCUMFERENCE_TYPES = [
|
||||
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
|
||||
];
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function CircumferenceDetailScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 日期相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -78,37 +80,37 @@ export default function CircumferenceDetailScreen() {
|
||||
const measurements = [
|
||||
{
|
||||
key: 'chestCircumference',
|
||||
label: '胸围',
|
||||
label: t('circumferenceDetail.measurements.chest'),
|
||||
value: userProfile?.chestCircumference,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
{
|
||||
key: 'waistCircumference',
|
||||
label: '腰围',
|
||||
label: t('circumferenceDetail.measurements.waist'),
|
||||
value: userProfile?.waistCircumference,
|
||||
color: '#4ECDC4',
|
||||
},
|
||||
{
|
||||
key: 'upperHipCircumference',
|
||||
label: '上臀围',
|
||||
label: t('circumferenceDetail.measurements.upperHip'),
|
||||
value: userProfile?.upperHipCircumference,
|
||||
color: '#45B7D1',
|
||||
},
|
||||
{
|
||||
key: 'armCircumference',
|
||||
label: '臂围',
|
||||
label: t('circumferenceDetail.measurements.arm'),
|
||||
value: userProfile?.armCircumference,
|
||||
color: '#96CEB4',
|
||||
},
|
||||
{
|
||||
key: 'thighCircumference',
|
||||
label: '大腿围',
|
||||
label: t('circumferenceDetail.measurements.thigh'),
|
||||
value: userProfile?.thighCircumference,
|
||||
color: '#FFEAA7',
|
||||
},
|
||||
{
|
||||
key: 'calfCircumference',
|
||||
label: '小腿围',
|
||||
label: t('circumferenceDetail.measurements.calf'),
|
||||
value: userProfile?.calfCircumference,
|
||||
color: '#DDA0DD',
|
||||
},
|
||||
@@ -243,10 +245,10 @@ export default function CircumferenceDetailScreen() {
|
||||
// 将YYYY-MM-DD格式转换为第几周
|
||||
const weekOfYear = dayjs(item.label).week();
|
||||
const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
|
||||
return `第${weekOfYear - firstWeekOfMonth + 1}周`;
|
||||
return t('circumferenceDetail.chart.weekLabel', { week: weekOfYear - firstWeekOfMonth + 1 });
|
||||
case 'year':
|
||||
// 将YYYY-MM格式转换为月份
|
||||
return dayjs(item.label).format('M月');
|
||||
return t('circumferenceDetail.chart.monthLabel', { month: dayjs(item.label).format('M') });
|
||||
default:
|
||||
return item.label;
|
||||
}
|
||||
@@ -287,7 +289,7 @@ export default function CircumferenceDetailScreen() {
|
||||
|
||||
{/* 头部导航 */}
|
||||
<HeaderBar
|
||||
title="围度统计"
|
||||
title={t('circumferenceDetail.title')}
|
||||
transparent
|
||||
/>
|
||||
|
||||
@@ -338,7 +340,7 @@ export default function CircumferenceDetailScreen() {
|
||||
|
||||
{/* 围度统计 */}
|
||||
<View style={styles.statsCard}>
|
||||
<Text style={styles.statsTitle}>围度统计</Text>
|
||||
<Text style={styles.statsTitle}>{t('circumferenceDetail.title')}</Text>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View style={styles.tabContainer}>
|
||||
@@ -348,7 +350,7 @@ export default function CircumferenceDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
|
||||
按周
|
||||
{t('circumferenceDetail.tabs.week')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -357,7 +359,7 @@ export default function CircumferenceDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||
按月
|
||||
{t('circumferenceDetail.tabs.month')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -366,7 +368,7 @@ export default function CircumferenceDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
|
||||
按年
|
||||
{t('circumferenceDetail.tabs.year')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -390,7 +392,7 @@ export default function CircumferenceDetailScreen() {
|
||||
styles.legendText,
|
||||
!isVisible && styles.legendTextHidden
|
||||
]}>
|
||||
{type.label}
|
||||
{t(`circumferenceDetail.measurements.${type.key.replace('Circumference', '').toLowerCase()}`)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -401,17 +403,17 @@ export default function CircumferenceDetailScreen() {
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingChart}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
<Text style={styles.loadingText}>{t('circumferenceDetail.loading')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorChart}>
|
||||
<Text style={styles.errorText}>加载失败: {error}</Text>
|
||||
<Text style={styles.errorText}>{t('circumferenceDetail.error')}: {error}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
<Text style={styles.retryText}>{t('circumferenceDetail.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : processedChartData.datasets.length > 0 ? (
|
||||
@@ -453,8 +455,8 @@ export default function CircumferenceDetailScreen() {
|
||||
<View style={styles.emptyChart}>
|
||||
<Text style={styles.emptyChartText}>
|
||||
{processedChartData.datasets.length === 0 && !isLoading && !error
|
||||
? '暂无数据'
|
||||
: '请选择要显示的围度数据'
|
||||
? t('circumferenceDetail.chart.empty')
|
||||
: t('circumferenceDetail.chart.noSelection')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -469,12 +471,12 @@ export default function CircumferenceDetailScreen() {
|
||||
setModalVisible(false);
|
||||
setSelectedMeasurement(null);
|
||||
}}
|
||||
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
|
||||
title={selectedMeasurement ? t('circumferenceDetail.modal.title', { label: selectedMeasurement.label }) : t('circumferenceDetail.modal.defaultTitle')}
|
||||
items={circumferenceOptions}
|
||||
selectedValue={selectedMeasurement?.currentValue}
|
||||
onValueChange={() => { }} // Real-time update not needed
|
||||
onConfirm={handleUpdateMeasurement}
|
||||
confirmButtonText="确认"
|
||||
confirmButtonText={t('circumferenceDetail.modal.confirm')}
|
||||
pickerHeight={180}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
fetchActivityRingsForDate,
|
||||
@@ -51,6 +52,7 @@ type WeekData = {
|
||||
};
|
||||
|
||||
export default function FitnessRingsDetailScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const colorScheme = useColorScheme();
|
||||
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||
@@ -82,7 +84,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
exerciseInfoAnim.setValue(0);
|
||||
}
|
||||
} 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++) {
|
||||
const currentDay = startOfWeek.add(i, '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 {
|
||||
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
|
||||
@@ -303,7 +313,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
setShowExerciseInfo(false);
|
||||
});
|
||||
} 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.cardHeader}>
|
||||
<Text style={styles.cardTitle}>活动热量</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.activeCalories.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -390,25 +400,25 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
|
||||
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>千卡</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(activeEnergyBurned)}千卡
|
||||
{Math.round(activeEnergyBurned)}{t('fitnessRingsDetail.cards.activeCalories.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyCaloriesData.map(h => h.calories),
|
||||
Math.max(activeEnergyBurnedGoal / 24, 1),
|
||||
'#FF3B30',
|
||||
'千卡'
|
||||
t('fitnessRingsDetail.cards.activeCalories.unit')
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 锻炼分钟卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>锻炼分钟数</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -418,18 +428,18 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#FF9500' }]}>
|
||||
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>分钟</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleExerciseTime)}分钟
|
||||
{Math.round(appleExerciseTime)}{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyExerciseData.map(h => h.minutes),
|
||||
Math.max(appleExerciseTimeGoal / 8, 1),
|
||||
'#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}>
|
||||
进行强度不低于"快走"的运动锻炼,就会积累对应时长的锻炼分钟数。
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.description')}
|
||||
</Text>
|
||||
<Text style={styles.exerciseRecommendation}>
|
||||
世卫组织推荐的成年人每天至少保持30分钟以上的中高强度运动。
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
|
||||
<Text style={styles.knowButtonText}>知道了</Text>
|
||||
<Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -467,7 +477,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 活动小时数卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>活动小时数</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -477,18 +487,18 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#007AFF' }]}>
|
||||
{Math.round(appleStandHours)}/{appleStandHoursGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>小时</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleStandHours)}小时
|
||||
{Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyStandData.map(h => h.hasStood),
|
||||
1,
|
||||
'#007AFF',
|
||||
'小时'
|
||||
t('fitnessRingsDetail.cards.standHours.unit')
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -536,9 +546,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 周闭环天数统计 */}
|
||||
<View style={styles.statsContainer}>
|
||||
<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}>
|
||||
<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>
|
||||
@@ -575,12 +585,12 @@ export default function FitnessRingsDetailScreen() {
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('fitnessRingsDetail.datePicker.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { createMedicationRecognitionTask } from '@/services/medications';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
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 captureSteps = [
|
||||
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
|
||||
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
|
||||
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
|
||||
{ key: 'front', mandatory: true },
|
||||
{ key: 'side', mandatory: true },
|
||||
{ key: 'aux', mandatory: false },
|
||||
] as const;
|
||||
|
||||
type CaptureKey = (typeof captureSteps)[number]['key'];
|
||||
@@ -51,6 +52,7 @@ type Shot = {
|
||||
};
|
||||
|
||||
export default function MedicationAiCameraScreen() {
|
||||
const { t } = useI18n();
|
||||
const insets = useSafeAreaInsets();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
@@ -113,7 +115,14 @@ export default function MedicationAiCameraScreen() {
|
||||
}
|
||||
}, [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(() => {
|
||||
@@ -149,7 +158,7 @@ export default function MedicationAiCameraScreen() {
|
||||
if (!result.canceled && result.assets?.length) {
|
||||
const asset = result.assets[0];
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
|
||||
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
@@ -159,7 +168,10 @@ export default function MedicationAiCameraScreen() {
|
||||
}
|
||||
} catch (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 });
|
||||
if (photo?.uri) {
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
|
||||
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
@@ -179,7 +191,10 @@ export default function MedicationAiCameraScreen() {
|
||||
}
|
||||
} catch (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 () => {
|
||||
// 检查必需照片是否完成
|
||||
if (!allRequiredCaptured) {
|
||||
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
|
||||
Alert.alert(
|
||||
t('medications.aiCamera.alerts.insufficientPhotos.title'),
|
||||
t('medications.aiCamera.alerts.insufficientPhotos.message')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,7 +227,9 @@ export default function MedicationAiCameraScreen() {
|
||||
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
|
||||
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' }),
|
||||
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({
|
||||
@@ -227,7 +247,10 @@ export default function MedicationAiCameraScreen() {
|
||||
});
|
||||
} catch (error: any) {
|
||||
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 {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
@@ -278,12 +301,16 @@ export default function MedicationAiCameraScreen() {
|
||||
isInteractive={true}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<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>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -440,12 +467,16 @@ export default function MedicationAiCameraScreen() {
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||||
<Text style={styles.splitButtonLabel}>
|
||||
{t('medications.aiCamera.buttons.capture')}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||||
<Text style={styles.splitButtonLabel}>
|
||||
{t('medications.aiCamera.buttons.capture')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -470,7 +501,9 @@ export default function MedicationAiCameraScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||||
<Text style={styles.splitButtonLabel}>
|
||||
{t('medications.aiCamera.buttons.complete')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
@@ -481,7 +514,9 @@ export default function MedicationAiCameraScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||||
<Text style={styles.splitButtonLabel}>
|
||||
{t('medications.aiCamera.buttons.complete')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -501,12 +536,25 @@ export default function MedicationAiCameraScreen() {
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<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 }]}>
|
||||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||
<Text style={styles.permissionTip}>授权后即可快速拍摄药品包装,自动识别信息</Text>
|
||||
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||||
<Text style={styles.permissionBtnText}>授权访问相机</Text>
|
||||
<Text style={styles.permissionTitle}>
|
||||
{t('medications.aiCamera.permission.title')}
|
||||
</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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -524,14 +572,14 @@ export default function MedicationAiCameraScreen() {
|
||||
<View style={styles.container}>
|
||||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar
|
||||
title="AI 用药识别"
|
||||
title={t('medications.aiCamera.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent
|
||||
right={
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowGuideModal(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="查看拍摄说明"
|
||||
accessibilityLabel={t('medications.aiCamera.guideModal.title')}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
@@ -556,8 +604,12 @@ export default function MedicationAiCameraScreen() {
|
||||
<View style={styles.metaBadge}>
|
||||
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
|
||||
</View>
|
||||
<Text style={styles.metaTitle}>{currentStep.title}</Text>
|
||||
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
|
||||
<Text style={styles.metaTitle}>
|
||||
{t(`medications.aiCamera.steps.${currentStep.key}.title`)}
|
||||
</Text>
|
||||
<Text style={styles.metaSubtitle}>
|
||||
{t(`medications.aiCamera.steps.${currentStep.key}.subtitle`)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraCard}>
|
||||
@@ -587,14 +639,22 @@ export default function MedicationAiCameraScreen() {
|
||||
style={[styles.shotCard, active && styles.shotCardActive]}
|
||||
>
|
||||
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||||
{step.title}
|
||||
{!step.mandatory ? '(可选)' : ''}
|
||||
{t(`medications.aiCamera.steps.${step.key}.title`)}
|
||||
{!step.mandatory
|
||||
? ` ${t('medications.aiCamera.steps.optional')}`
|
||||
: ''}
|
||||
</Text>
|
||||
{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}>
|
||||
<Text style={styles.shotPlaceholderText}>未拍摄</Text>
|
||||
<Text style={styles.shotPlaceholderText}>
|
||||
{t('medications.aiCamera.steps.notTaken')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -617,12 +677,16 @@ export default function MedicationAiCameraScreen() {
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
<Text style={styles.secondaryBtnText}>
|
||||
{t('medications.aiCamera.buttons.album')}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
<Text style={styles.secondaryBtnText}>
|
||||
{t('medications.aiCamera.buttons.album')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
type TabConfig,
|
||||
} from '@/store/tabBarConfigSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
@@ -25,14 +23,14 @@ import {
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
export default function TabBarConfigScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const safeAreaTop = useSafeAreaTop(60);
|
||||
const configs = useAppSelector(selectTabBarConfigs);
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 处理开关切换
|
||||
const handleToggle = useCallback(
|
||||
|
||||
1180
app/sleep-detail.tsx
1180
app/sleep-detail.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function StepsDetailScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
// 获取路由参数
|
||||
@@ -169,11 +171,11 @@ export default function StepsDetailScreen() {
|
||||
|
||||
// 活动等级配置
|
||||
const activityLevels = useMemo(() => [
|
||||
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
|
||||
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
|
||||
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
|
||||
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
|
||||
], []);
|
||||
{ key: 'inactive', label: t('stepsDetail.activityLevel.levels.inactive'), minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
|
||||
{ key: 'light', label: t('stepsDetail.activityLevel.levels.light'), minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
|
||||
{ key: 'moderate', label: t('stepsDetail.activityLevel.levels.moderate'), minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
|
||||
{ key: 'very_active', label: t('stepsDetail.activityLevel.levels.very_active'), minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
|
||||
], [t]);
|
||||
|
||||
// 计算当前活动等级
|
||||
const currentActivityLevel = useMemo(() => {
|
||||
@@ -211,7 +213,7 @@ export default function StepsDetailScreen() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="步数详情"
|
||||
title={t('stepsDetail.title')}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
@@ -233,23 +235,23 @@ export default function StepsDetailScreen() {
|
||||
<View style={styles.statsCard}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
<Text style={styles.loadingText}>{t('stepsDetail.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>总步数</Text>
|
||||
<Text style={styles.statLabel}>{t('stepsDetail.stats.totalSteps')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{averageHourlySteps}</Text>
|
||||
<Text style={styles.statLabel}>平均每小时</Text>
|
||||
<Text style={styles.statLabel}>{t('stepsDetail.stats.averagePerHour')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>
|
||||
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>最活跃时段</Text>
|
||||
<Text style={styles.statLabel}>{t('stepsDetail.stats.mostActiveTime')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -258,7 +260,7 @@ export default function StepsDetailScreen() {
|
||||
{/* 详细柱状图卡片 */}
|
||||
<View style={styles.chartCard}>
|
||||
<View style={styles.chartHeader}>
|
||||
<Text style={styles.chartTitle}>每小时步数分布</Text>
|
||||
<Text style={styles.chartTitle}>{t('stepsDetail.chart.title')}</Text>
|
||||
<Text style={styles.chartSubtitle}>
|
||||
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
@@ -290,7 +292,7 @@ export default function StepsDetailScreen() {
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.averageLineLabel}>
|
||||
平均 {averageHourlySteps}步
|
||||
{t('stepsDetail.chart.averageLabel', { steps: averageHourlySteps })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -354,9 +356,9 @@ export default function StepsDetailScreen() {
|
||||
|
||||
{/* 底部时间轴标签 */}
|
||||
<View style={styles.timeLabels}>
|
||||
<Text style={styles.timeLabel}>0:00</Text>
|
||||
<Text style={styles.timeLabel}>12:00</Text>
|
||||
<Text style={styles.timeLabel}>24:00</Text>
|
||||
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.midnight')}</Text>
|
||||
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.noon')}</Text>
|
||||
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.nextDay')}</Text>
|
||||
</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>
|
||||
|
||||
{/* 进度条 */}
|
||||
@@ -388,14 +390,14 @@ export default function StepsDetailScreen() {
|
||||
<View style={styles.stepsInfoContainer}>
|
||||
<View style={styles.currentStepsInfo}>
|
||||
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} 步</Text>
|
||||
<Text style={styles.stepsLabel}>当前</Text>
|
||||
<Text style={styles.stepsLabel}>{t('stepsDetail.activityLevel.progress.current')}</Text>
|
||||
</View>
|
||||
<View style={styles.nextStepsInfo}>
|
||||
<Text style={styles.stepsValue}>
|
||||
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'}
|
||||
</Text>
|
||||
<Text style={styles.stepsLabel}>
|
||||
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
|
||||
{nextActivityLevel ? t('stepsDetail.activityLevel.progress.nextLevel', { level: nextActivityLevel.label }) : t('stepsDetail.activityLevel.progress.highestLevel')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -28,6 +30,7 @@ interface WaterDetailProps {
|
||||
}
|
||||
|
||||
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||
@@ -37,22 +40,14 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
||||
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
||||
|
||||
// Remove modal states as they are now in separate settings page
|
||||
|
||||
// 使用新的 hook 来处理指定日期的饮水数据
|
||||
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
|
||||
|
||||
|
||||
|
||||
|
||||
// 处理设置按钮点击 - 跳转到设置页面
|
||||
const handleSettingsPress = () => {
|
||||
router.push('/water/settings');
|
||||
};
|
||||
|
||||
// Remove all modal-related functions as they are now in separate settings page
|
||||
|
||||
|
||||
// 删除饮水记录
|
||||
const handleDeleteRecord = async (recordId: string) => {
|
||||
await removeWaterRecord(recordId);
|
||||
@@ -70,13 +65,17 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
setDailyGoal(dailyWaterGoal.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户偏好设置失败:', error);
|
||||
console.error(t('waterDetail.loadingUserPreferences'), error);
|
||||
}
|
||||
};
|
||||
|
||||
loadUserPreferences();
|
||||
}, [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 swipeableRef = React.useRef<Swipeable>(null);
|
||||
@@ -84,15 +83,15 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
// 处理删除操作
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条饮水记录吗?此操作无法撤销。',
|
||||
t('waterDetail.deleteConfirm.title'),
|
||||
t('waterDetail.deleteConfirm.message'),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('waterDetail.deleteConfirm.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
text: t('waterDetail.deleteConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete();
|
||||
@@ -112,7 +111,6 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.deleteSwipeButtonText}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -125,29 +123,29 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
rightThreshold={40}
|
||||
overshootRight={false}
|
||||
>
|
||||
<View style={[styles.recordCard, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<View style={styles.recordCard}>
|
||||
<View style={styles.recordMainContent}>
|
||||
<View style={[styles.recordIconContainer, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={styles.recordIconContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/IconGlass.png')}
|
||||
style={styles.recordIcon}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.recordInfo}>
|
||||
<Text style={[styles.recordLabel, { color: colorTokens.text }]}>水</Text>
|
||||
<Text style={styles.recordLabel}>{t('waterDetail.water')}</Text>
|
||||
<View style={styles.recordTimeContainer}>
|
||||
<Ionicons name="time-outline" size={14} color={colorTokens.textSecondary} />
|
||||
<Text style={[styles.recordTimeText, { color: colorTokens.textSecondary }]}>
|
||||
<Ionicons name="time-outline" size={14} color="#6f7ba7" />
|
||||
<Text style={styles.recordTimeText}>
|
||||
{dayjs(record.recordedAt || record.createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<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>
|
||||
{record.note && (
|
||||
<Text style={[styles.recordNote, { color: colorTokens.textSecondary }]}>{record.note}</Text>
|
||||
<Text style={styles.recordNote}>{record.note}</Text>
|
||||
)}
|
||||
</View>
|
||||
</Swipeable>
|
||||
@@ -157,32 +155,47 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
{/* 背景 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={['#f3f4fb', '#f3f4fb']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
{/* 顶部装饰性渐变 - 模仿挑战页面的柔和背景感 */}
|
||||
<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
|
||||
title="饮水详情"
|
||||
onBack={() => {
|
||||
// 这里会通过路由自动处理返回
|
||||
router.back();
|
||||
}}
|
||||
title={t('waterDetail.title')}
|
||||
onBack={() => router.back()}
|
||||
right={
|
||||
<TouchableOpacity
|
||||
style={styles.settingsButton}
|
||||
onPress={handleSettingsPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
|
||||
</TouchableOpacity>
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleSettingsPress}
|
||||
activeOpacity={0.7}
|
||||
style={styles.settingsButtonWrapper}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
|
||||
{/* 第二部分:饮水记录 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>
|
||||
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}饮水记录
|
||||
<View style={styles.headerBlock}>
|
||||
<Text style={styles.pageTitle}>
|
||||
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : t('waterDetail.today')}
|
||||
</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 ? (
|
||||
<View style={styles.recordsList}>
|
||||
{waterRecords.map((record) => (
|
||||
@@ -213,29 +250,20 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
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 style={styles.noRecordsContainer}>
|
||||
<Ionicons name="water-outline" size={48} color={colorTokens.textSecondary} />
|
||||
<Text style={[styles.noRecordsText, { color: colorTokens.textSecondary }]}>暂无饮水记录</Text>
|
||||
<Text style={[styles.noRecordsSubText, { color: colorTokens.textSecondary }]}>点击"添加记录"开始记录饮水量</Text>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/IconGlass.png')}
|
||||
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>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* All modals have been moved to the separate water-settings page */}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -245,32 +273,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#f3f4fb',
|
||||
},
|
||||
gradientBackground: {
|
||||
topGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
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,
|
||||
height: 300,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
@@ -279,54 +287,107 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
headerBlock: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 36,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginTop: 10,
|
||||
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,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
letterSpacing: -0.3,
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
color: '#6f7ba7',
|
||||
marginLeft: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
// 饮水记录相关样式
|
||||
progressBarBg: {
|
||||
height: 12,
|
||||
backgroundColor: '#F0F2F5',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 6,
|
||||
},
|
||||
|
||||
section: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
|
||||
// 记录列表样式
|
||||
recordsList: {
|
||||
gap: 16,
|
||||
},
|
||||
recordCardContainer: {
|
||||
// iOS 阴影效果 - 增强阴影效果
|
||||
shadowColor: 'rgba(30, 41, 59, 0.18)',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 16,
|
||||
// Android 阴影效果
|
||||
elevation: 6,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
recordCard: {
|
||||
borderRadius: 20,
|
||||
borderRadius: 24,
|
||||
padding: 18,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
recordMainContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
recordIconContainer: {
|
||||
width: 48,
|
||||
@@ -334,7 +395,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.08)',
|
||||
backgroundColor: '#f5f6ff',
|
||||
},
|
||||
recordIcon: {
|
||||
width: 24,
|
||||
@@ -345,15 +406,21 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 16,
|
||||
},
|
||||
recordLabel: {
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
marginBottom: 6,
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
recordTimeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
gap: 4,
|
||||
},
|
||||
recordTimeText: {
|
||||
fontSize: 13,
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
recordAmountContainer: {
|
||||
alignItems: 'flex-end',
|
||||
@@ -362,364 +429,74 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#4F5BD5',
|
||||
},
|
||||
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',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
recordNote: {
|
||||
marginTop: 12,
|
||||
marginTop: 14,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.04)',
|
||||
backgroundColor: '#F8F9FC',
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
fontStyle: 'normal',
|
||||
lineHeight: 20,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: '#5f6a97',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
recordsSummary: {
|
||||
marginTop: 24,
|
||||
padding: 20,
|
||||
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',
|
||||
deleteSwipeButton: {
|
||||
backgroundColor: '#FF6B6B',
|
||||
justifyContent: '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: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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: {
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
lineHeight: 24,
|
||||
color: '#6f7ba7',
|
||||
color: '#1c1f3a',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
noRecordsSubText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
color: '#9ba3c7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
|
||||
// Settings Button
|
||||
settingsButtonWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
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,
|
||||
settingsButtonGlass: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalBtnPrimary: {
|
||||
// backgroundColor will be set dynamically
|
||||
},
|
||||
modalBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalBtnTextPrimary: {
|
||||
// color will be set dynamically
|
||||
},
|
||||
settingsButton: {
|
||||
settingsButtonFallback: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.24)',
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
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,
|
||||
borderColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
const WaterReminderSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -71,9 +73,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
setStartTimePickerVisible(false);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'时间设置提示',
|
||||
'开始时间不能晚于或等于结束时间,请重新选择',
|
||||
[{ text: '确定' }]
|
||||
t('waterReminderSettings.alerts.timeValidation.title'),
|
||||
t('waterReminderSettings.alerts.timeValidation.startTimeInvalid'),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm') }]
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -91,9 +93,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
setEndTimePickerVisible(false);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'时间设置提示',
|
||||
'结束时间不能早于或等于开始时间,请重新选择',
|
||||
[{ text: '确定' }]
|
||||
t('waterReminderSettings.alerts.timeValidation.title'),
|
||||
t('waterReminderSettings.alerts.timeValidation.endTimeInvalid'),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm') }]
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -125,18 +127,28 @@ const WaterReminderSettings: React.FC = () => {
|
||||
|
||||
if (waterReminderSettings.enabled) {
|
||||
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
|
||||
const intervalInfo = `每${waterReminderSettings.interval}分钟`;
|
||||
const intervalInfo = `${waterReminderSettings.interval}${t('waterReminderSettings.labels.minutes')}`;
|
||||
Alert.alert(
|
||||
'设置成功',
|
||||
`喝水提醒已开启\n\n时间段:${timeInfo}\n提醒间隔:${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`,
|
||||
[{ text: '确定', onPress: () => router.back() }]
|
||||
t('waterReminderSettings.alerts.success.enabled'),
|
||||
t('waterReminderSettings.alerts.success.enabledMessage', {
|
||||
timeRange: timeInfo,
|
||||
interval: intervalInfo
|
||||
}),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
|
||||
);
|
||||
} 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) {
|
||||
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} />
|
||||
|
||||
<HeaderBar
|
||||
title="喝水提醒"
|
||||
title={t('waterReminderSettings.title')}
|
||||
onBack={() => {
|
||||
router.back();
|
||||
}}
|
||||
@@ -198,7 +210,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<View style={styles.waterReminderSectionHeader}>
|
||||
<View style={styles.waterReminderSectionTitleContainer}>
|
||||
<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>
|
||||
<Switch
|
||||
value={waterReminderSettings.enabled}
|
||||
@@ -208,7 +220,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||
开启后将在指定时间段内定期推送喝水提醒
|
||||
{t('waterReminderSettings.descriptions.notifications')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -216,15 +228,15 @@ const WaterReminderSettings: React.FC = () => {
|
||||
{waterReminderSettings.enabled && (
|
||||
<>
|
||||
<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 }]}>
|
||||
只在指定时间段内发送提醒,避免打扰您的休息
|
||||
{t('waterReminderSettings.descriptions.timeRange')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.timeRangeContainer}>
|
||||
{/* 开始时间 */}
|
||||
<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
|
||||
style={[styles.timePicker, { backgroundColor: 'white' }]}
|
||||
onPress={openStartTimePicker}
|
||||
@@ -236,7 +248,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
|
||||
{/* 结束时间 */}
|
||||
<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
|
||||
style={[styles.timePicker, { backgroundColor: 'white' }]}
|
||||
onPress={openEndTimePicker}
|
||||
@@ -250,9 +262,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
|
||||
{/* 提醒间隔设置 */}
|
||||
<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 }]}>
|
||||
选择提醒的频率,建议30-120分钟为宜
|
||||
{t('waterReminderSettings.descriptions.interval')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.intervalContainer}>
|
||||
@@ -263,7 +275,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
style={styles.intervalPicker}
|
||||
>
|
||||
{[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>
|
||||
</View>
|
||||
@@ -279,7 +291,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
onPress={handleWaterReminderSave}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>保存设置</Text>
|
||||
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.labels.saveSettings')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -295,11 +307,11 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
|
||||
<View style={styles.timePickerModalSheet}>
|
||||
<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.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}>
|
||||
<Picker
|
||||
selectedValue={tempStartHour}
|
||||
@@ -314,12 +326,12 @@ const WaterReminderSettings: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<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 }]}>
|
||||
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
|
||||
</Text>
|
||||
{!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>
|
||||
@@ -329,13 +341,13 @@ const WaterReminderSettings: React.FC = () => {
|
||||
onPress={() => setStartTimePickerVisible(false)}
|
||||
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
|
||||
onPress={confirmStartTime}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -351,11 +363,11 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
|
||||
<View style={styles.timePickerModalSheet}>
|
||||
<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.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}>
|
||||
<Picker
|
||||
selectedValue={tempEndHour}
|
||||
@@ -370,12 +382,12 @@ const WaterReminderSettings: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<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 }]}>
|
||||
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
|
||||
</Text>
|
||||
{!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>
|
||||
@@ -385,13 +397,13 @@ const WaterReminderSettings: React.FC = () => {
|
||||
onPress={() => setEndTimePickerVisible(false)}
|
||||
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
|
||||
onPress={confirmEndTime}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
const WaterSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -74,7 +76,10 @@ const WaterSettings: React.FC = () => {
|
||||
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 {
|
||||
await setQuickWaterAmount(tempQuickAdd);
|
||||
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`);
|
||||
Alert.alert(
|
||||
t('waterSettings.alerts.quickAddSuccess.title'),
|
||||
t('waterSettings.alerts.quickAddSuccess.message', { amount: tempQuickAdd })
|
||||
);
|
||||
} 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();
|
||||
setWaterReminderSettings(reminderSettings);
|
||||
} 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} />
|
||||
|
||||
<HeaderBar
|
||||
title="饮水设置"
|
||||
title={t('waterSettings.title')}
|
||||
onBack={() => {
|
||||
router.back();
|
||||
}}
|
||||
@@ -156,8 +167,8 @@ const WaterSettings: React.FC = () => {
|
||||
<Ionicons name="flag-outline" size={20} color="#9370DB" />
|
||||
</View>
|
||||
<View style={styles.settingsMenuItemContent}>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}{t('waterSettings.labels.ml')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
@@ -169,11 +180,11 @@ const WaterSettings: React.FC = () => {
|
||||
<Ionicons name="add-outline" size={20} color="#9370DB" />
|
||||
</View>
|
||||
<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 }]}>
|
||||
设置点击"+"按钮时添加的默认饮水量
|
||||
{t('waterSettings.descriptions.quickAdd')}
|
||||
</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>
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
@@ -185,12 +196,19 @@ const WaterSettings: React.FC = () => {
|
||||
<Ionicons name="notifications-outline" size={20} color="#3498DB" />
|
||||
</View>
|
||||
<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 }]}>
|
||||
设置定时提醒您补充水分
|
||||
{t('waterSettings.descriptions.reminder')}
|
||||
</Text>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -211,7 +229,7 @@ const WaterSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
|
||||
<View style={styles.modalSheet}>
|
||||
<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}>
|
||||
<Picker
|
||||
selectedValue={tempGoal}
|
||||
@@ -219,7 +237,7 @@ const WaterSettings: React.FC = () => {
|
||||
style={styles.picker}
|
||||
>
|
||||
{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>
|
||||
</View>
|
||||
@@ -228,13 +246,13 @@ const WaterSettings: React.FC = () => {
|
||||
onPress={() => setGoalModalVisible(false)}
|
||||
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
|
||||
onPress={handleGoalConfirm}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -250,7 +268,7 @@ const WaterSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
|
||||
<View style={styles.modalSheet}>
|
||||
<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}>
|
||||
<Picker
|
||||
selectedValue={tempQuickAdd}
|
||||
@@ -258,7 +276,7 @@ const WaterSettings: React.FC = () => {
|
||||
style={styles.picker}
|
||||
>
|
||||
{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>
|
||||
</View>
|
||||
@@ -267,13 +285,13 @@ const WaterSettings: React.FC = () => {
|
||||
onPress={() => setQuickAddModalVisible(false)}
|
||||
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
|
||||
onPress={handleQuickAddConfirm}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function WeightRecordsPage() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -43,7 +45,7 @@ export default function WeightRecordsPage() {
|
||||
try {
|
||||
await dispatch(fetchWeightHistory() as any);
|
||||
} catch (error) {
|
||||
console.error('加载体重历史失败:', error);
|
||||
console.error(t('weightRecords.loadingHistory'), error);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -92,15 +94,15 @@ export default function WeightRecordsPage() {
|
||||
await dispatch(deleteWeightRecord(id) as any);
|
||||
await loadWeightHistory();
|
||||
} catch (error) {
|
||||
console.error('删除体重记录失败:', error);
|
||||
Alert.alert('错误', '删除体重记录失败,请重试');
|
||||
console.error(t('weightRecords.alerts.deleteFailed'), error);
|
||||
Alert.alert('错误', t('weightRecords.alerts.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeightSave = async () => {
|
||||
const weight = parseFloat(inputWeight);
|
||||
if (isNaN(weight) || weight <= 0 || weight > 500) {
|
||||
alert('请输入有效的体重值(0-500kg)');
|
||||
alert(t('weightRecords.alerts.invalidWeight'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,8 +132,8 @@ export default function WeightRecordsPage() {
|
||||
setEditingRecord(null);
|
||||
await loadWeightHistory();
|
||||
} catch (error) {
|
||||
console.error('保存体重失败:', error);
|
||||
Alert.alert('错误', '保存体重失败,请重试');
|
||||
console.error(t('weightRecords.alerts.saveFailed'), error);
|
||||
Alert.alert('错误', t('weightRecords.alerts.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -190,7 +192,7 @@ export default function WeightRecordsPage() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="体重记录"
|
||||
title={t('weightRecords.title')}
|
||||
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
|
||||
<Ionicons name="add" size={24} color="#192126" />
|
||||
</TouchableOpacity>}
|
||||
@@ -204,27 +206,27 @@ export default function WeightRecordsPage() {
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<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 style={styles.statItem}>
|
||||
<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 style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
|
||||
<View style={styles.statLabelContainer}>
|
||||
<Text style={styles.statLabel}>初始体重</Text>
|
||||
<Text style={styles.statLabel}>{t('weightRecords.stats.initialWeight')}</Text>
|
||||
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
|
||||
<Ionicons name="create-outline" size={14} color="#FF9500" />
|
||||
<Ionicons name="create-outline" size={12} color="#FF9500" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
|
||||
<View style={styles.statLabelContainer}>
|
||||
<Text style={styles.statLabel}>目标体重</Text>
|
||||
<Text style={styles.statLabel}>{t('weightRecords.stats.targetWeight')}</Text>
|
||||
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
|
||||
<Ionicons name="create-outline" size={14} color="#FF9500" />
|
||||
<Ionicons name="create-outline" size={12} color="#FF9500" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -282,8 +284,8 @@ export default function WeightRecordsPage() {
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyText}>暂无体重记录</Text>
|
||||
<Text style={styles.emptySubtext}>点击右上角添加按钮开始记录</Text>
|
||||
<Text style={styles.emptyText}>{t('weightRecords.empty.title')}</Text>
|
||||
<Text style={styles.emptySubtext}>{t('weightRecords.empty.subtitle')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -309,10 +311,10 @@ export default function WeightRecordsPage() {
|
||||
<Ionicons name="close" size={24} color={themeColors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
|
||||
{pickerType === 'current' && '记录体重'}
|
||||
{pickerType === 'initial' && '编辑初始体重'}
|
||||
{pickerType === 'target' && '编辑目标体重'}
|
||||
{pickerType === 'edit' && '编辑体重记录'}
|
||||
{pickerType === 'current' && t('weightRecords.modal.recordWeight')}
|
||||
{pickerType === 'initial' && t('weightRecords.modal.editInitialWeight')}
|
||||
{pickerType === 'target' && t('weightRecords.modal.editTargetWeight')}
|
||||
{pickerType === 'edit' && t('weightRecords.modal.editRecord')}
|
||||
</Text>
|
||||
<View style={{ width: 24 }} />
|
||||
</View>
|
||||
@@ -329,21 +331,21 @@ export default function WeightRecordsPage() {
|
||||
</View>
|
||||
<View style={styles.inputWrapper}>
|
||||
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
|
||||
{inputWeight || '输入体重'}
|
||||
{inputWeight || t('weightRecords.modal.inputPlaceholder')}
|
||||
</Text>
|
||||
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
||||
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.modal.unit')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Weight Range Hint */}
|
||||
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
|
||||
请输入 0-500 之间的数值,支持小数
|
||||
{t('weightRecords.modal.inputHint')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Quick Selection */}
|
||||
<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}>
|
||||
{[50, 60, 70, 80, 90].map((weight) => (
|
||||
<TouchableOpacity
|
||||
@@ -358,7 +360,7 @@ export default function WeightRecordsPage() {
|
||||
styles.quickButtonText,
|
||||
inputWeight === weight.toString() && styles.quickButtonTextSelected
|
||||
]}>
|
||||
{weight}kg
|
||||
{weight}{t('weightRecords.modal.unit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -386,7 +388,7 @@ export default function WeightRecordsPage() {
|
||||
onPress={handleWeightSave}
|
||||
disabled={!inputWeight.trim()}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>确定</Text>
|
||||
<Text style={styles.saveButtonText}>{t('weightRecords.modal.confirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -461,7 +463,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
@@ -475,6 +477,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
color: '#687076',
|
||||
marginRight: 4,
|
||||
textAlign: 'center'
|
||||
},
|
||||
editIcon: {
|
||||
padding: 2,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
||||
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) {
|
||||
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
||||
return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
|
||||
}
|
||||
|
||||
const minutes = Math.max(durationInSeconds / 60, 1);
|
||||
const caloriesPerMinute = totalCalories / minutes;
|
||||
|
||||
if (caloriesPerMinute >= 9) {
|
||||
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
|
||||
return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -265,13 +266,14 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
||||
return Object.keys(grouped)
|
||||
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
|
||||
.map((dateKey) => ({
|
||||
title: dayjs(dateKey).format('M月D日'),
|
||||
title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
|
||||
data: grouped[dateKey]
|
||||
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function WorkoutHistoryScreen() {
|
||||
const { t } = useI18n();
|
||||
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -284,7 +286,7 @@ export default function WorkoutHistoryScreen() {
|
||||
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
||||
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
||||
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -302,7 +304,7 @@ export default function WorkoutHistoryScreen() {
|
||||
|
||||
if (!hasPermission) {
|
||||
setSections([]);
|
||||
setError('尚未授予健康数据权限');
|
||||
setError(t('workoutHistory.error.permissionDenied'));
|
||||
setMonthlyStats(null);
|
||||
return;
|
||||
}
|
||||
@@ -315,8 +317,8 @@ export default function WorkoutHistoryScreen() {
|
||||
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
|
||||
setSections(groupWorkouts(filteredWorkouts));
|
||||
} catch (err) {
|
||||
console.error('加载锻炼历史失败:', err);
|
||||
setError('加载锻炼记录失败,请稍后再试');
|
||||
console.error('Failed to load workout history:', err);
|
||||
setError(t('workoutHistory.error.loadFailed'));
|
||||
setSections([]);
|
||||
setMonthlyStats(null);
|
||||
} finally {
|
||||
@@ -350,9 +352,9 @@ export default function WorkoutHistoryScreen() {
|
||||
? dayjs(monthlyStats.snapshotDate).format('M月D日')
|
||||
: dayjs().format('M月D日');
|
||||
const overviewText = monthlyStats
|
||||
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}。`
|
||||
: '本月还没有锻炼记录,动起来收集第一条吧!';
|
||||
const periodText = `统计周期:1日 - ${monthEndDay}日(本月)`;
|
||||
? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
|
||||
: t('workoutHistory.monthlyStats.overviewEmpty');
|
||||
const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay });
|
||||
const maxDuration = statsItems[0]?.duration || 1;
|
||||
|
||||
return (
|
||||
@@ -369,7 +371,7 @@ export default function WorkoutHistoryScreen() {
|
||||
end={{ x: 1, y: 1 }}
|
||||
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.statDescription}>{overviewText}</Text>
|
||||
|
||||
@@ -403,7 +405,7 @@ export default function WorkoutHistoryScreen() {
|
||||
) : (
|
||||
<View style={styles.statEmptyState}>
|
||||
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
|
||||
<Text style={styles.statEmptyText}>本月还没有锻炼数据</Text>
|
||||
<Text style={styles.statEmptyText}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</LinearGradient>
|
||||
@@ -416,8 +418,8 @@ export default function WorkoutHistoryScreen() {
|
||||
const emptyComponent = useMemo(() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
|
||||
<Text style={styles.emptyText}>暂无锻炼记录</Text>
|
||||
<Text style={styles.emptySubText}>完成一次锻炼后即可在此查看详细历史</Text>
|
||||
<Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
|
||||
<Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
@@ -453,7 +455,7 @@ export default function WorkoutHistoryScreen() {
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
|
||||
@@ -463,16 +465,16 @@ export default function WorkoutHistoryScreen() {
|
||||
const metrics = await getWorkoutDetailMetrics(workout);
|
||||
setDetailMetrics(metrics);
|
||||
} catch (err) {
|
||||
console.error('加载锻炼详情失败:', err);
|
||||
console.error('Failed to load workout details:', err);
|
||||
setDetailMetrics(null);
|
||||
setDetailError('加载锻炼详情失败,请稍后再试');
|
||||
setDetailError(t('workoutHistory.error.detailLoadFailed'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
|
||||
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
|
||||
const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
|
||||
setSelectedIntensity(intensity);
|
||||
setSelectedWorkout(workout);
|
||||
setDetailMetrics(null);
|
||||
@@ -495,7 +497,7 @@ export default function WorkoutHistoryScreen() {
|
||||
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
|
||||
const calories = Math.round(item.totalEnergyBurned || 0);
|
||||
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 time = dayjs(item.startDate || item.endDate).format('HH:mm');
|
||||
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
||||
@@ -512,12 +514,12 @@ export default function WorkoutHistoryScreen() {
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
<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 }]}>
|
||||
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>{activityLabel},{time}</Text>
|
||||
<Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
|
||||
</View>
|
||||
|
||||
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
||||
@@ -535,11 +537,11 @@ export default function WorkoutHistoryScreen() {
|
||||
colors={["#F3F5FF", "#FFFFFF"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} />
|
||||
<HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#5C55FF" />
|
||||
<Text style={styles.loadingText}>正在加载锻炼记录...</Text>
|
||||
<Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
@@ -556,7 +558,7 @@ export default function WorkoutHistoryScreen() {
|
||||
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
|
||||
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
<Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : emptyComponent}
|
||||
|
||||
@@ -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 DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
autoScrollToSelected = true,
|
||||
showCalendarIcon = true,
|
||||
}) => {
|
||||
const { t, i18n } = useI18n();
|
||||
|
||||
// 内部状态管理
|
||||
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
||||
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取日期数据
|
||||
const days = getMonthDaysZh(currentMonth);
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||
const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
|
||||
|
||||
// 判断当前选中的日期是否是今天
|
||||
const isSelectedDateToday = () => {
|
||||
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
setCurrentMonth(selectedMonth);
|
||||
|
||||
// 计算选中日期在新月份中的索引
|
||||
const newMonthDays = getMonthDaysZh(selectedMonth);
|
||||
const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
|
||||
const selectedDay = selectedMonth.date();
|
||||
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
|
||||
|
||||
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const handleGoToday = () => {
|
||||
const today = dayjs();
|
||||
setCurrentMonth(today);
|
||||
const todayDays = getMonthDaysZh(today);
|
||||
const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
|
||||
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
|
||||
|
||||
if (newSelectedIndex !== -1) {
|
||||
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
tintColor="rgba(124, 58, 237, 0.08)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.todayButton, styles.todayButtonFallback]}>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||
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) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||
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) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import type { RankingItem } from '@/store/challengesSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
|
||||
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) {
|
||||
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 targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
|
||||
const progressLabel = reportedLabel && targetLabel
|
||||
? `今日 ${reportedLabel} / ${targetLabel}`
|
||||
? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
|
||||
: reportedLabel
|
||||
? `今日 ${reportedLabel}`
|
||||
? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
|
||||
: targetLabel
|
||||
? `今日目标 ${targetLabel}`
|
||||
? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
@@ -25,6 +26,8 @@ interface MedicationPhotoGuideModalProps {
|
||||
* 展示如何正确拍摄药品照片的说明和示例
|
||||
*/
|
||||
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -48,8 +51,12 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
|
||||
>
|
||||
{/* 标题部分 */}
|
||||
<View style={styles.guideHeader}>
|
||||
<Text style={styles.guideStepBadge}>规范</Text>
|
||||
<Text style={styles.guideTitle}>拍摄图片清晰</Text>
|
||||
<Text style={styles.guideStepBadge}>
|
||||
{t('medications.aiCamera.guideModal.badge')}
|
||||
</Text>
|
||||
<Text style={styles.guideTitle}>
|
||||
{t('medications.aiCamera.guideModal.title')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 示例图片 */}
|
||||
@@ -99,10 +106,10 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
|
||||
{/* 说明文字 */}
|
||||
<View style={styles.guideDescription}>
|
||||
<Text style={styles.guideDescriptionText}>
|
||||
请拍摄药品正面\背面的产品名称\说明部分。
|
||||
{t('medications.aiCamera.guideModal.description1')}
|
||||
</Text>
|
||||
<Text style={styles.guideDescriptionText}>
|
||||
注意拍摄时光线充分,没有反光,文字部分清晰可见。照片的清晰度会影响识别的准确率。
|
||||
{t('medications.aiCamera.guideModal.description2')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -124,7 +131,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.guideConfirmButtonGradient}
|
||||
>
|
||||
<Text style={styles.guideConfirmButtonText}>知道了!</Text>
|
||||
<Text style={styles.guideConfirmButtonText}>
|
||||
{t('medications.aiCamera.guideModal.button')}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</GlassView>
|
||||
) : (
|
||||
@@ -135,7 +144,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.guideConfirmButtonGradient}
|
||||
>
|
||||
<Text style={styles.guideConfirmButtonText}>知道了!</Text>
|
||||
<Text style={styles.guideConfirmButtonText}>
|
||||
{t('medications.aiCamera.guideModal.button')}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
// 睡眠详情数据类型
|
||||
export type SleepDetailData = {
|
||||
@@ -41,15 +42,22 @@ const SleepGradeCard = ({
|
||||
range: string;
|
||||
isActive?: boolean;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
const getGradeColor = (grade: string) => {
|
||||
switch (grade) {
|
||||
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' };
|
||||
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' };
|
||||
case '良好': return { bg: '#D1FAE5', text: '#065F46' };
|
||||
case '优秀': return { bg: '#FEF3C7', text: '#92400E' };
|
||||
case t('sleepDetail.sleepGrades.low'):
|
||||
case t('sleepDetail.sleepGrades.poor'):
|
||||
return { bg: '#FECACA', text: '#DC2626' };
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -97,6 +105,7 @@ export const InfoModal = ({
|
||||
type: 'sleep-time' | 'sleep-quality';
|
||||
sleepData: SleepDetailData;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const slideAnim = useState(new Animated.Value(0))[0];
|
||||
@@ -153,26 +162,26 @@ export const InfoModal = ({
|
||||
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
|
||||
|
||||
const sleepTimeGrades = [
|
||||
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
|
||||
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
|
||||
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.low'), range: '< 6h', isActive: currentSleepTimeGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.normal'), range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
|
||||
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
|
||||
];
|
||||
|
||||
const sleepQualityGrades = [
|
||||
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
|
||||
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
|
||||
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.poor'), range: '< 55%', isActive: currentSleepQualityGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.fair'), range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
|
||||
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
|
||||
];
|
||||
|
||||
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
|
||||
|
||||
const getDescription = () => {
|
||||
if (type === 'sleep-time') {
|
||||
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。';
|
||||
return t('sleepDetail.sleepTimeDescription');
|
||||
} else {
|
||||
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长,还包括睡眠的连续性和各睡眠阶段的平衡。';
|
||||
return t('sleepDetail.sleepQualityDescription');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,18 +7,24 @@ import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
||||
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export type SleepStageTimelineProps = {
|
||||
sleepSamples: SleepSample[];
|
||||
bedtime: string;
|
||||
wakeupTime: string;
|
||||
onInfoPress?: () => void;
|
||||
hideHeader?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export const SleepStageTimeline = ({
|
||||
sleepSamples,
|
||||
bedtime,
|
||||
wakeupTime,
|
||||
onInfoPress
|
||||
onInfoPress,
|
||||
hideHeader = false,
|
||||
style
|
||||
}: SleepStageTimelineProps) => {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -130,15 +136,17 @@ export const SleepStageTimeline = ({
|
||||
// 如果没有数据,显示空状态
|
||||
if (timelineData.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
|
||||
{!hideHeader && (
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
|
||||
暂无睡眠阶段数据
|
||||
@@ -149,16 +157,18 @@ export const SleepStageTimeline = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
|
||||
{/* 标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{!hideHeader && (
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 睡眠时间范围 */}
|
||||
<View style={styles.timeRange}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
// Sleep Stages Info Modal 组件
|
||||
export const SleepStagesInfoModal = ({
|
||||
@@ -22,6 +23,7 @@ export const SleepStagesInfoModal = ({
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const slideAnim = useState(new Animated.Value(0))[0];
|
||||
@@ -82,7 +84,7 @@ export const SleepStagesInfoModal = ({
|
||||
|
||||
<View style={styles.sleepStagesModalHeader}>
|
||||
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
|
||||
了解你的睡眠阶段
|
||||
{t('sleepDetail.sleepStagesInfo.title')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
|
||||
<Ionicons name="close" size={24} color={colorTokens.textSecondary} />
|
||||
@@ -97,7 +99,7 @@ export const SleepStagesInfoModal = ({
|
||||
scrollEnabled={true}
|
||||
>
|
||||
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
|
||||
人们对睡眠阶段和睡眠质量有许多误解。有些人可能需要更多深度睡眠,其他人则不然。科学家和医生仍在探索不同睡眠阶段的作用及其对身体的影响。通过跟踪睡眠阶段并留意每天清晨的感受,你或许能深入了解自己的睡眠。
|
||||
{t('sleepDetail.sleepStagesInfo.description')}
|
||||
</Text>
|
||||
|
||||
{/* 清醒时间 */}
|
||||
@@ -105,11 +107,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<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>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
一次睡眠期间,你可能会醒来几次。偶尔醒来很正常。可能你会立刻再次入睡,并不记得曾在夜间醒来。
|
||||
{t('sleepDetail.sleepStagesInfo.awake.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -118,11 +120,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<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>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
这一睡眠阶段可能对学习和记忆产生一定影响。在此阶段,你的肌肉最为放松,眼球也会快速左右移动。这也是你大多数梦境出现的阶段。
|
||||
{t('sleepDetail.sleepStagesInfo.rem.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -131,11 +133,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<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>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
这一阶段有时也称为浅睡期,与其他阶段一样重要。此阶段通常占据你每晚大部分的睡眠时间。对于认知至关重要的脑电波会在这一阶段产生。
|
||||
{t('sleepDetail.sleepStagesInfo.core.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -144,11 +146,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<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>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
因为脑电波的特征,这一阶段也称为慢波睡眠。在此阶段,身体组织得到修复,并释放重要荷尔蒙。它通常出现在睡眠的前半段,且持续时间较长。深度睡眠期间,身体非常放松,因此相较于其他阶段,你可能更难在此阶段醒来。
|
||||
{t('sleepDetail.sleepStagesInfo.deep.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { WeightHistoryItem } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -20,6 +21,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||
onDelete,
|
||||
weightChange = 0
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
const colorScheme = useColorScheme();
|
||||
const themeColors = Colors[colorScheme ?? 'light'];
|
||||
@@ -27,15 +29,15 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||
// 处理删除操作
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
`确定要删除这条体重记录吗?此操作无法撤销。`,
|
||||
t('weightRecords.card.deleteConfirmTitle'),
|
||||
t('weightRecords.card.deleteConfirmMessage'),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('weightRecords.card.cancelButton'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
text: t('weightRecords.card.deleteButton'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
const recordId = record.id || record.createdAt;
|
||||
@@ -56,7 +58,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.deleteButtonText}>删除</Text>
|
||||
<Text style={styles.deleteButtonText}>{t('weightRecords.card.deleteButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -73,7 +75,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||
>
|
||||
<View style={styles.recordHeader}>
|
||||
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
|
||||
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
|
||||
{dayjs(record.createdAt).format('MM[月]DD[日] HH:mm')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.recordEditButton}
|
||||
@@ -83,8 +85,8 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.recordContent}>
|
||||
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}>体重:</Text>
|
||||
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}kg</Text>
|
||||
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}>{t('weightRecords.card.weightLabel')}:</Text>
|
||||
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}{t('weightRecords.modal.unit')}</Text>
|
||||
{Math.abs(weightChange) > 0 && (
|
||||
<View style={[
|
||||
styles.weightChangeTag,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
HeartRateZoneStat,
|
||||
WorkoutDetailMetrics,
|
||||
@@ -59,6 +60,7 @@ export function WorkoutDetailModal({
|
||||
onRetry,
|
||||
errorMessage,
|
||||
}: WorkoutDetailModalProps) {
|
||||
const { t } = useI18n();
|
||||
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
|
||||
const [isMounted, setIsMounted] = useState(visible);
|
||||
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
||||
@@ -229,26 +231,26 @@ export function WorkoutDetailModal({
|
||||
{loading ? (
|
||||
<View style={styles.loadingBlock}>
|
||||
<ActivityIndicator color="#5C55FF" />
|
||||
<Text style={styles.loadingLabel}>正在加载锻炼详情...</Text>
|
||||
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
|
||||
</View>
|
||||
) : metrics ? (
|
||||
<>
|
||||
<View style={styles.metricsRow}>
|
||||
<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>
|
||||
</View>
|
||||
<View style={styles.metricItem}>
|
||||
<Text style={styles.metricTitle}>运动热量</Text>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
|
||||
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={styles.metricItem}>
|
||||
<View style={styles.metricTitleRow}>
|
||||
<Text style={styles.metricTitle}>运动强度</Text>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowIntensityInfo(true)}
|
||||
style={styles.metricInfoButton}
|
||||
@@ -262,9 +264,9 @@ export function WorkoutDetailModal({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metricItem}>
|
||||
<Text style={styles.metricTitle}>平均心率</Text>
|
||||
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
|
||||
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -275,11 +277,11 @@ export function WorkoutDetailModal({
|
||||
) : (
|
||||
<View style={styles.errorBlock}>
|
||||
<Text style={styles.errorText}>
|
||||
{errorMessage || '未能获取到完整的锻炼详情'}
|
||||
{errorMessage || t('workoutDetail.errors.loadFailed')}
|
||||
</Text>
|
||||
{onRetry ? (
|
||||
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
||||
<Text style={styles.retryButtonText}>重新加载</Text>
|
||||
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -288,7 +290,7 @@ export function WorkoutDetailModal({
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>心率范围</Text>
|
||||
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -299,21 +301,21 @@ export function WorkoutDetailModal({
|
||||
<>
|
||||
<View style={styles.heartRateSummaryRow}>
|
||||
<View style={styles.heartRateStat}>
|
||||
<Text style={styles.statLabel}>平均心率</Text>
|
||||
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
|
||||
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.heartRateStat}>
|
||||
<Text style={styles.statLabel}>最高心率</Text>
|
||||
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
|
||||
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.heartRateStat}>
|
||||
<Text style={styles.statLabel}>最低心率</Text>
|
||||
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
|
||||
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -336,7 +338,7 @@ export function WorkoutDetailModal({
|
||||
width={Dimensions.get('window').width - 72}
|
||||
height={220}
|
||||
fromZero={false}
|
||||
yAxisSuffix="次/分"
|
||||
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
|
||||
withInnerLines={false}
|
||||
bezier
|
||||
chartConfig={{
|
||||
@@ -360,20 +362,20 @@ export function WorkoutDetailModal({
|
||||
) : (
|
||||
<View style={styles.chartEmpty}>
|
||||
<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 style={styles.chartEmpty}>
|
||||
<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 style={styles.sectionError}>
|
||||
<Text style={styles.errorTextSmall}>
|
||||
{errorMessage || '未获取到心率数据'}
|
||||
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -381,7 +383,7 @@ export function WorkoutDetailModal({
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>心率训练区间</Text>
|
||||
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
@@ -391,7 +393,7 @@ export function WorkoutDetailModal({
|
||||
) : metrics ? (
|
||||
metrics.heartRateZones.map(renderHeartRateZone)
|
||||
) : (
|
||||
<Text style={styles.errorTextSmall}>暂无区间统计</Text>
|
||||
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -410,36 +412,36 @@ export function WorkoutDetailModal({
|
||||
<TouchableWithoutFeedback onPress={() => { }}>
|
||||
<View style={styles.intensityInfoSheet}>
|
||||
<View style={styles.intensityHandle} />
|
||||
<Text style={styles.intensityInfoTitle}>什么是运动强度?</Text>
|
||||
<Text style={styles.intensityInfoTitle}>{t('workoutDetail.intensityInfo.title')}</Text>
|
||||
<Text style={styles.intensityInfoText}>
|
||||
运动强度是你完成一项任务所用的能量估算,是衡量锻炼和其他日常活动能耗强度的指标,单位为 MET(千卡/(千克·小时))。
|
||||
{t('workoutDetail.intensityInfo.description1')}
|
||||
</Text>
|
||||
<Text style={styles.intensityInfoText}>
|
||||
因为每个人的代谢状况不同,MET 以身体的静息能耗作为参考,便于衡量不同活动的强度。
|
||||
{t('workoutDetail.intensityInfo.description2')}
|
||||
</Text>
|
||||
<Text style={styles.intensityInfoText}>
|
||||
例如:散步(约 3 km/h)相当于 2 METs,意味着它需要消耗静息状态 2 倍的能量。
|
||||
{t('workoutDetail.intensityInfo.description3')}
|
||||
</Text>
|
||||
<Text style={styles.intensityInfoText}>
|
||||
注:当设备未提供 METs 值时,系统会根据您的卡路里消耗和锻炼时长自动计算(使用70公斤估算体重)。
|
||||
{t('workoutDetail.intensityInfo.description4')}
|
||||
</Text>
|
||||
<View style={styles.intensityFormula}>
|
||||
<Text style={styles.intensityFormulaLabel}>运动强度计算公式</Text>
|
||||
<Text style={styles.intensityFormulaValue}>METs = 活动能耗(千卡/小时) ÷ 静息能耗(1 千卡/小时)</Text>
|
||||
<Text style={styles.intensityFormulaLabel}>{t('workoutDetail.intensityInfo.formula.title')}</Text>
|
||||
<Text style={styles.intensityFormulaValue}>{t('workoutDetail.intensityInfo.formula.value')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.intensityLegend}>
|
||||
<View style={styles.intensityLegendRow}>
|
||||
<Text style={styles.intensityLegendRange}>{'< 3'}</Text>
|
||||
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>低强度活动</Text>
|
||||
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.low')}</Text>
|
||||
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>{t('workoutDetail.intensityInfo.legend.lowLabel')}</Text>
|
||||
</View>
|
||||
<View style={styles.intensityLegendRow}>
|
||||
<Text style={styles.intensityLegendRange}>3 - 6</Text>
|
||||
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>中强度活动</Text>
|
||||
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.medium')}</Text>
|
||||
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>{t('workoutDetail.intensityInfo.legend.mediumLabel')}</Text>
|
||||
</View>
|
||||
<View style={styles.intensityLegendRow}>
|
||||
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text>
|
||||
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>高强度活动</Text>
|
||||
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.high')}</Text>
|
||||
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>{t('workoutDetail.intensityInfo.legend.highLabel')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Alert } from 'react-native';
|
||||
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { STORAGE_KEYS, api } from '@/services/api';
|
||||
import { logout as logoutAction } from '@/store/userSlice';
|
||||
|
||||
@@ -21,6 +22,7 @@ export function useAuthGuard() {
|
||||
const dispatch = useAppDispatch();
|
||||
const currentPath = usePathname();
|
||||
const user = useAppSelector(state => state.user);
|
||||
const { t } = useI18n();
|
||||
|
||||
// 判断登录状态:优先使用 token,因为 token 是登录的根本凭证
|
||||
// profile.id 可能在初始化时还未加载,但 token 已经从 AsyncStorage 恢复
|
||||
@@ -74,28 +76,28 @@ export function useAuthGuard() {
|
||||
router.push('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
Alert.alert('错误', '退出登录失败,请稍后重试');
|
||||
Alert.alert(t('authGuard.logout.error'), t('authGuard.logout.errorMessage'));
|
||||
}
|
||||
}, [dispatch, router]);
|
||||
}, [dispatch, router, t]);
|
||||
|
||||
// 带确认对话框的退出登录
|
||||
const confirmLogout = useCallback(() => {
|
||||
Alert.alert(
|
||||
'确认退出',
|
||||
'确定要退出当前账号吗?',
|
||||
t('authGuard.confirmLogout.title'),
|
||||
t('authGuard.confirmLogout.message'),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('authGuard.confirmLogout.cancelButton'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '确定',
|
||||
text: t('authGuard.confirmLogout.confirmButton'),
|
||||
style: 'default',
|
||||
onPress: handleLogout,
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [handleLogout]);
|
||||
}, [handleLogout, t]);
|
||||
|
||||
// 注销账号功能
|
||||
const handleDeleteAccount = useCallback(async () => {
|
||||
@@ -109,38 +111,38 @@ export function useAuthGuard() {
|
||||
// 执行退出登录逻辑
|
||||
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'),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
console.error('注销账号失败:', error);
|
||||
const message = error?.message || '注销失败,请稍后重试';
|
||||
Alert.alert('注销失败', message);
|
||||
const message = error?.message || t('authGuard.deleteAccount.errorMessage');
|
||||
Alert.alert(t('authGuard.deleteAccount.errorTitle'), message);
|
||||
}
|
||||
}, [dispatch, router]);
|
||||
}, [dispatch, router, t]);
|
||||
|
||||
// 带确认对话框的注销账号
|
||||
const confirmDeleteAccount = useCallback(() => {
|
||||
Alert.alert(
|
||||
'确认注销账号',
|
||||
'此操作不可恢复,将删除您的账号及相关数据。确定继续吗?',
|
||||
t('authGuard.confirmDeleteAccount.title'),
|
||||
t('authGuard.confirmDeleteAccount.message'),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('authGuard.confirmDeleteAccount.cancelButton'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '确认注销',
|
||||
text: t('authGuard.confirmDeleteAccount.confirmButton'),
|
||||
style: 'destructive',
|
||||
onPress: handleDeleteAccount,
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
}, [handleDeleteAccount]);
|
||||
}, [handleDeleteAccount, t]);
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
|
||||
1201
i18n/index.ts
1201
i18n/index.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/en';
|
||||
import '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 {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user