feat(i18n): 增强生理周期模块的国际化支持,添加多语言格式和翻译
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
deleteMenstrualFlow,
|
||||
fetchMenstrualFlowSamples,
|
||||
@@ -29,14 +30,22 @@ type TabKey = 'cycle' | 'analysis';
|
||||
|
||||
export default function MenstrualCycleScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
||||
const locale = i18n.language.startsWith('en') ? 'en' : 'zh';
|
||||
const monthTitleFormat = t('menstrual.dateFormats.monthTitle', { defaultValue: 'M月' });
|
||||
const monthSubtitleFormat = t('menstrual.dateFormats.monthSubtitle', { defaultValue: 'YYYY年' });
|
||||
const weekLabels = useMemo(() => {
|
||||
const labels = t('menstrual.weekdays', { returnObjects: true }) as string[];
|
||||
return Array.isArray(labels) && labels.length === 7 ? labels : undefined;
|
||||
}, [t]);
|
||||
|
||||
// Load data from HealthKit
|
||||
// 从 HealthKit 拉取当前窗口范围内的经期数据
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
// Calculate date range based on windowConfig
|
||||
// 根据 windowConfig 计算需要拉取的月份区间
|
||||
const today = dayjs();
|
||||
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
|
||||
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
|
||||
@@ -49,6 +58,7 @@ export default function MenstrualCycleScreen() {
|
||||
loadData();
|
||||
}, [windowConfig]);
|
||||
|
||||
// 根据记录生成时间轴(包含预测周期、易孕期等)
|
||||
const timeline = useMemo(
|
||||
() =>
|
||||
buildMenstrualTimeline({
|
||||
@@ -56,8 +66,11 @@ export default function MenstrualCycleScreen() {
|
||||
monthsAfter: windowConfig.after,
|
||||
records,
|
||||
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
||||
locale,
|
||||
monthTitleFormat,
|
||||
monthSubtitleFormat,
|
||||
}),
|
||||
[records, windowConfig]
|
||||
[records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
|
||||
const [selectedDateKey, setSelectedDateKey] = useState(
|
||||
@@ -67,11 +80,28 @@ export default function MenstrualCycleScreen() {
|
||||
const offsetRef = useRef(0);
|
||||
const prependDeltaRef = useRef(0);
|
||||
const loadingPrevRef = useRef(false);
|
||||
const hasAutoScrolledRef = useRef(false);
|
||||
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
|
||||
|
||||
const selectedInfo = timeline.dayMap[selectedDateKey];
|
||||
const selectedDate = dayjs(selectedDateKey);
|
||||
const initialMonthIndex = useMemo(
|
||||
() => timeline.months.findIndex((month) => month.id === todayMonthId),
|
||||
[timeline.months, todayMonthId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAutoScrolledRef.current) return;
|
||||
if (initialMonthIndex < 0 || !listRef.current) return;
|
||||
hasAutoScrolledRef.current = true;
|
||||
offsetRef.current = initialMonthIndex * ITEM_HEIGHT;
|
||||
requestAnimationFrame(() => {
|
||||
listRef.current?.scrollToIndex({ index: initialMonthIndex, animated: false });
|
||||
});
|
||||
}, [initialMonthIndex]);
|
||||
|
||||
|
||||
// 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
|
||||
const handleMarkStart = async () => {
|
||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||
|
||||
@@ -176,6 +206,7 @@ export default function MenstrualCycleScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 取消选中日期的经期标记(与 HealthKit 同步)
|
||||
const handleCancelMark = async () => {
|
||||
if (!selectedInfo || !selectedInfo.confirmed) return;
|
||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||
@@ -237,6 +268,7 @@ export default function MenstrualCycleScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 下拉到顶部时加载更早的月份
|
||||
const handleLoadPrevious = () => {
|
||||
if (loadingPrevRef.current) return;
|
||||
loadingPrevRef.current = true;
|
||||
@@ -245,6 +277,7 @@ export default function MenstrualCycleScreen() {
|
||||
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
|
||||
};
|
||||
|
||||
// 向前追加月份时,保持当前视口位置不跳动
|
||||
useEffect(() => {
|
||||
if (prependDeltaRef.current > 0 && listRef.current) {
|
||||
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
|
||||
@@ -260,6 +293,7 @@ export default function MenstrualCycleScreen() {
|
||||
viewAreaCoveragePercentThreshold: 10,
|
||||
}).current;
|
||||
|
||||
// 监测可视区域,接近顶部时触发加载更早月份
|
||||
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
|
||||
const minIndex = viewableItems.reduce(
|
||||
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
|
||||
@@ -272,6 +306,7 @@ export default function MenstrualCycleScreen() {
|
||||
|
||||
|
||||
|
||||
// FlatList 数据源:按月份拆分
|
||||
const listData = useMemo(() => {
|
||||
return timeline.months.map((m) => ({
|
||||
type: 'month' as const,
|
||||
@@ -305,6 +340,7 @@ export default function MenstrualCycleScreen() {
|
||||
selectedDateKey={selectedDateKey}
|
||||
onSelect={(key) => setSelectedDateKey(key)}
|
||||
renderTip={renderInlineTip}
|
||||
weekLabels={weekLabels}
|
||||
/>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -313,6 +349,19 @@ export default function MenstrualCycleScreen() {
|
||||
maxToRenderPerBatch={4}
|
||||
removeClippedSubviews
|
||||
contentContainerStyle={styles.listContent}
|
||||
// 使用固定高度优化初始滚动定位
|
||||
getItemLayout={(_, index) => ({
|
||||
length: ITEM_HEIGHT,
|
||||
offset: ITEM_HEIGHT * index,
|
||||
index,
|
||||
})}
|
||||
initialScrollIndex={initialMonthIndex >= 0 ? initialMonthIndex : undefined}
|
||||
onScrollToIndexFailed={({ index }) => {
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: ITEM_HEIGHT * index,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
viewabilityConfig={viewabilityConfig}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
onScroll={(e) => {
|
||||
@@ -344,15 +393,30 @@ export default function MenstrualCycleScreen() {
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
||||
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text>
|
||||
<TouchableOpacity style={styles.headerIcon}>
|
||||
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<HeaderBar
|
||||
title={t('menstrual.screen.header')}
|
||||
onBack={() => router.back()}
|
||||
// right={
|
||||
// isLiquidGlassAvailable() ? (
|
||||
// <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
|
||||
// <GlassView
|
||||
// style={styles.headerIconGlass}
|
||||
// glassEffectStyle="clear"
|
||||
// tintColor="rgba(255, 255, 255, 0.35)"
|
||||
// isInteractive={true}
|
||||
// >
|
||||
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||
// </GlassView>
|
||||
// </TouchableOpacity>
|
||||
// ) : (
|
||||
// <TouchableOpacity style={styles.headerIcon} activeOpacity={0.7}>
|
||||
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||
// </TouchableOpacity>
|
||||
// )
|
||||
// }
|
||||
/>
|
||||
|
||||
<View style={{ height: safeAreaTop }} />
|
||||
|
||||
<View style={styles.tabSwitcher}>
|
||||
{([
|
||||
@@ -383,29 +447,29 @@ export default function MenstrualCycleScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 52,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
fontFamily: 'AliBold',
|
||||
headerIconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerIconGlass: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabSwitcher: {
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user