diff --git a/app/menstrual-cycle.tsx b/app/menstrual-cycle.tsx index 31f4daf..da03881 100644 --- a/app/menstrual-cycle.tsx +++ b/app/menstrual-cycle.tsx @@ -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([]); 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('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 }} /> - - router.back()} style={styles.headerIcon}> - - - {t('menstrual.screen.header')} - - - - + router.back()} + // right={ + // isLiquidGlassAvailable() ? ( + // + // + // + // + // + // ) : ( + // + // + // + // ) + // } + /> + + {([ @@ -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', diff --git a/components/MenstrualCycleCard.tsx b/components/MenstrualCycleCard.tsx index 82ba0b2..3f3870b 100644 --- a/components/MenstrualCycleCard.tsx +++ b/components/MenstrualCycleCard.tsx @@ -1,11 +1,11 @@ -import { LinearGradient } from 'expo-linear-gradient'; import dayjs, { Dayjs } from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useMemo, useState } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Colors } from '@/constants/Colors'; -import { fetchMenstrualFlowSamples } from '@/utils/health'; +import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health'; import { buildMenstrualTimeline, convertHealthKitSamplesToCycleRecords, @@ -49,7 +49,10 @@ export const MenstrualCycleCard: React.FC = ({ onPress }) => { useEffect(() => { let mounted = true; const loadMenstrualData = async () => { - setLoading(true); + // Avoid setting loading to true for background updates to prevent UI flicker + if (records.length === 0) { + setLoading(true); + } try { const today = dayjs(); const startDate = today.subtract(3, 'month').startOf('month').toDate(); @@ -72,8 +75,16 @@ export const MenstrualCycleCard: React.FC = ({ onPress }) => { }; loadMenstrualData(); + + // Listen for data changes + const handleDataChange = () => { + loadMenstrualData(); + }; + healthDataEvents.on('menstrualDataChanged', handleDataChange); + return () => { mounted = false; + healthDataEvents.off('menstrualDataChanged', handleDataChange); }; }, []); diff --git a/components/menstrual-cycle/DayCell.tsx b/components/menstrual-cycle/DayCell.tsx index 48fb0da..a040105 100644 --- a/components/menstrual-cycle/DayCell.tsx +++ b/components/menstrual-cycle/DayCell.tsx @@ -1,10 +1,12 @@ import { Colors } from '@/constants/Colors'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { STATUS_COLORS } from './constants'; import { DayCellProps } from './types'; export const DayCell: React.FC = ({ cell, isSelected, onPress }) => { + const { t } = useTranslation(); const status = cell.info?.status; const colors = status ? STATUS_COLORS[status] : undefined; @@ -32,7 +34,7 @@ export const DayCell: React.FC = ({ cell, isSelected, onPress }) = {cell.label} - {cell.isToday && 今天} + {cell.isToday && {t('menstrual.today')}} ); }; diff --git a/components/menstrual-cycle/InlineTip.tsx b/components/menstrual-cycle/InlineTip.tsx index c7340e0..b1082f6 100644 --- a/components/menstrual-cycle/InlineTip.tsx +++ b/components/menstrual-cycle/InlineTip.tsx @@ -1,7 +1,10 @@ import { Colors } from '@/constants/Colors'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; +import 'dayjs/locale/en'; +import 'dayjs/locale/zh-cn'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { InlineTipProps } from './types'; @@ -12,9 +15,12 @@ export const InlineTip: React.FC = ({ onMarkStart, onCancelMark, }) => { + const { t, i18n } = useTranslation(); // 14.28% per cell. Center is 7.14%. const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue; const isFuture = selectedDate.isAfter(dayjs(), 'day'); + const localeKey = i18n.language.startsWith('en') ? 'en' : 'zh-cn'; + const dateFormat = t('menstrual.dateFormatShort', { defaultValue: 'M月D日' }); return ( @@ -22,17 +28,19 @@ export const InlineTip: React.FC = ({ - {selectedDate.format('M月D日')} + + {selectedDate.locale(localeKey).format(dateFormat)} + {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && ( - 标记经期 + {t('menstrual.actions.markPeriod')} )} {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && ( - 取消标记 + {t('menstrual.actions.cancelMark')} )} diff --git a/components/menstrual-cycle/Legend.tsx b/components/menstrual-cycle/Legend.tsx index 05aabce..24b3950 100644 --- a/components/menstrual-cycle/Legend.tsx +++ b/components/menstrual-cycle/Legend.tsx @@ -1,19 +1,21 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { StyleSheet, Text, View } from 'react-native'; import { STATUS_COLORS } from './constants'; import { LegendItem } from './types'; -const LEGEND_ITEMS: LegendItem[] = [ - { label: '经期', key: 'period' }, - { label: '预测经期', key: 'predicted-period' }, - { label: '排卵期', key: 'fertile' }, - { label: '排卵日', key: 'ovulation-day' }, -]; - export const Legend: React.FC = () => { + const { t } = useTranslation(); + const legendItems: LegendItem[] = [ + { label: t('menstrual.legend.period'), key: 'period' }, + { label: t('menstrual.legend.predictedPeriod'), key: 'predicted-period' }, + { label: t('menstrual.legend.fertile'), key: 'fertile' }, + { label: t('menstrual.legend.ovulation'), key: 'ovulation-day' }, + ]; + return ( - {LEGEND_ITEMS.map((item) => ( + {legendItems.map((item) => ( void; renderTip: (colIndex: number) => React.ReactNode; + weekLabels?: string[]; } export const MonthBlock: React.FC = ({ @@ -24,8 +25,10 @@ export const MonthBlock: React.FC = ({ selectedDateKey, onSelect, renderTip, + weekLabels, }) => { const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]); + const labels = weekLabels?.length === 7 ? weekLabels : WEEK_LABELS; return ( @@ -34,7 +37,7 @@ export const MonthBlock: React.FC = ({ {month.subtitle} - {WEEK_LABELS.map((label) => ( + {labels.map((label) => ( {label} diff --git a/components/workout/WorkoutDetailModal.tsx b/components/workout/WorkoutDetailModal.tsx index 3fb1c76..901f0fa 100644 --- a/components/workout/WorkoutDetailModal.tsx +++ b/components/workout/WorkoutDetailModal.tsx @@ -9,7 +9,6 @@ import { ActivityIndicator, Dimensions, Modal, - Platform, Pressable, ScrollView, Share, @@ -19,7 +18,7 @@ import { TouchableWithoutFeedback, View, } from 'react-native'; -import ViewShot from 'react-native-view-shot'; +import ViewShot, { captureRef } from 'react-native-view-shot'; import { useI18n } from '@/hooks/useI18n'; import { @@ -156,7 +155,7 @@ export function WorkoutDetailModal({ type: 'info', text1: t('workoutDetail.share.generating', '正在生成分享卡片…'), }); - const uri = await shareContentRef.current.capture?.({ + const uri = await captureRef(shareContentRef, { format: 'png', quality: 0.95, snapshotContentContainer: true, @@ -164,6 +163,7 @@ export function WorkoutDetailModal({ if (!uri) { throw new Error('share-capture-failed'); } + const shareUri = uri.startsWith('file://') ? uri : `file://${uri}`; const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') }); const caloriesLabel = metrics?.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` @@ -179,7 +179,7 @@ export function WorkoutDetailModal({ await Share.share({ title: shareTitle, message: shareMessage, - url: Platform.OS === 'ios' ? uri : `file://${uri}`, + url: shareUri, }); } catch (error) { console.warn('workout-detail-share-failed', error); @@ -487,7 +487,6 @@ export function WorkoutDetailModal({ { const today = dayjs(); const monthsBefore = options?.monthsBefore ?? 2; @@ -267,6 +272,11 @@ export const buildMenstrualTimeline = (options?: { const months: MenstrualMonth[] = []; let monthCursor = startMonth.startOf('month'); + const locale = options?.locale ?? 'zh'; + const localeKey = locale === 'en' ? 'en' : 'zh-cn'; + const monthTitleFormat = options?.monthTitleFormat ?? (locale === 'en' ? 'MMM' : 'M月'); + const monthSubtitleFormat = options?.monthSubtitleFormat ?? (locale === 'en' ? 'YYYY' : 'YYYY年'); + while (monthCursor.isBefore(endMonth) || monthCursor.isSame(endMonth, 'month')) { const firstDay = monthCursor.startOf('month'); const daysInMonth = firstDay.daysInMonth(); @@ -298,10 +308,12 @@ export const buildMenstrualTimeline = (options?: { }); } + const formattedMonth = firstDay.locale(localeKey); + months.push({ id: firstDay.format('YYYY-MM'), - title: firstDay.format('M月'), - subtitle: firstDay.format('YYYY年'), + title: formattedMonth.format(monthTitleFormat), + subtitle: formattedMonth.format(monthSubtitleFormat), cells, });