feat(i18n): 增强生理周期模块的国际化支持,添加多语言格式和翻译

This commit is contained in:
richarjiang
2025-12-18 09:36:08 +08:00
parent 4836058d56
commit feb5052fcd
11 changed files with 192 additions and 52 deletions

View File

@@ -1,8 +1,8 @@
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
FlatList, FlatList,
StyleSheet, StyleSheet,
@@ -10,9 +10,10 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle'; import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
deleteMenstrualFlow, deleteMenstrualFlow,
fetchMenstrualFlowSamples, fetchMenstrualFlowSamples,
@@ -29,14 +30,22 @@ type TabKey = 'cycle' | 'analysis';
export default function MenstrualCycleScreen() { export default function MenstrualCycleScreen() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const safeAreaTop = useSafeAreaTop();
const [records, setRecords] = useState<CycleRecord[]>([]); const [records, setRecords] = useState<CycleRecord[]>([]);
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 }); 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(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
// Calculate date range based on windowConfig // 根据 windowConfig 计算需要拉取的月份区间
const today = dayjs(); const today = dayjs();
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate(); const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate(); const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
@@ -49,6 +58,7 @@ export default function MenstrualCycleScreen() {
loadData(); loadData();
}, [windowConfig]); }, [windowConfig]);
// 根据记录生成时间轴(包含预测周期、易孕期等)
const timeline = useMemo( const timeline = useMemo(
() => () =>
buildMenstrualTimeline({ buildMenstrualTimeline({
@@ -56,8 +66,11 @@ export default function MenstrualCycleScreen() {
monthsAfter: windowConfig.after, monthsAfter: windowConfig.after,
records, records,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH, defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
locale,
monthTitleFormat,
monthSubtitleFormat,
}), }),
[records, windowConfig] [records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
); );
const [activeTab, setActiveTab] = useState<TabKey>('cycle'); const [activeTab, setActiveTab] = useState<TabKey>('cycle');
const [selectedDateKey, setSelectedDateKey] = useState( const [selectedDateKey, setSelectedDateKey] = useState(
@@ -67,11 +80,28 @@ export default function MenstrualCycleScreen() {
const offsetRef = useRef(0); const offsetRef = useRef(0);
const prependDeltaRef = useRef(0); const prependDeltaRef = useRef(0);
const loadingPrevRef = useRef(false); const loadingPrevRef = useRef(false);
const hasAutoScrolledRef = useRef(false);
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
const selectedInfo = timeline.dayMap[selectedDateKey]; const selectedInfo = timeline.dayMap[selectedDateKey];
const selectedDate = dayjs(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 () => { const handleMarkStart = async () => {
if (selectedDate.isAfter(dayjs(), 'day')) return; if (selectedDate.isAfter(dayjs(), 'day')) return;
@@ -176,6 +206,7 @@ export default function MenstrualCycleScreen() {
} }
}; };
// 取消选中日期的经期标记(与 HealthKit 同步)
const handleCancelMark = async () => { const handleCancelMark = async () => {
if (!selectedInfo || !selectedInfo.confirmed) return; if (!selectedInfo || !selectedInfo.confirmed) return;
if (selectedDate.isAfter(dayjs(), 'day')) return; if (selectedDate.isAfter(dayjs(), 'day')) return;
@@ -237,6 +268,7 @@ export default function MenstrualCycleScreen() {
} }
}; };
// 下拉到顶部时加载更早的月份
const handleLoadPrevious = () => { const handleLoadPrevious = () => {
if (loadingPrevRef.current) return; if (loadingPrevRef.current) return;
loadingPrevRef.current = true; loadingPrevRef.current = true;
@@ -245,6 +277,7 @@ export default function MenstrualCycleScreen() {
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta })); setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
}; };
// 向前追加月份时,保持当前视口位置不跳动
useEffect(() => { useEffect(() => {
if (prependDeltaRef.current > 0 && listRef.current) { if (prependDeltaRef.current > 0 && listRef.current) {
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT; const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
@@ -260,6 +293,7 @@ export default function MenstrualCycleScreen() {
viewAreaCoveragePercentThreshold: 10, viewAreaCoveragePercentThreshold: 10,
}).current; }).current;
// 监测可视区域,接近顶部时触发加载更早月份
const onViewableItemsChanged = useRef(({ viewableItems }: any) => { const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
const minIndex = viewableItems.reduce( const minIndex = viewableItems.reduce(
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc), (acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
@@ -272,6 +306,7 @@ export default function MenstrualCycleScreen() {
// FlatList 数据源:按月份拆分
const listData = useMemo(() => { const listData = useMemo(() => {
return timeline.months.map((m) => ({ return timeline.months.map((m) => ({
type: 'month' as const, type: 'month' as const,
@@ -305,6 +340,7 @@ export default function MenstrualCycleScreen() {
selectedDateKey={selectedDateKey} selectedDateKey={selectedDateKey}
onSelect={(key) => setSelectedDateKey(key)} onSelect={(key) => setSelectedDateKey(key)}
renderTip={renderInlineTip} renderTip={renderInlineTip}
weekLabels={weekLabels}
/> />
)} )}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -313,6 +349,19 @@ export default function MenstrualCycleScreen() {
maxToRenderPerBatch={4} maxToRenderPerBatch={4}
removeClippedSubviews removeClippedSubviews
contentContainerStyle={styles.listContent} 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} viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged} onViewableItemsChanged={onViewableItemsChanged}
onScroll={(e) => { onScroll={(e) => {
@@ -344,15 +393,30 @@ export default function MenstrualCycleScreen() {
end={{ x: 0, y: 1 }} end={{ x: 0, y: 1 }}
/> />
<View style={styles.header}> <HeaderBar
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}> title={t('menstrual.screen.header')}
<Ionicons name="chevron-back" size={22} color="#0f172a" /> onBack={() => router.back()}
</TouchableOpacity> // right={
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text> // isLiquidGlassAvailable() ? (
<TouchableOpacity style={styles.headerIcon}> // <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
<Ionicons name="settings-outline" size={20} color="#0f172a" /> // <GlassView
</TouchableOpacity> // style={styles.headerIconGlass}
</View> // 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}> <View style={styles.tabSwitcher}>
{([ {([
@@ -383,29 +447,29 @@ export default function MenstrualCycleScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
paddingTop: 52,
paddingHorizontal: 16, paddingHorizontal: 16,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
headerIcon: { headerIcon: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.9)', backgroundColor: 'rgba(255, 255, 255, 0.5)',
}, },
headerTitle: { headerIconButton: {
fontSize: 18, width: 36,
fontWeight: '800', height: 36,
color: '#0f172a', borderRadius: 18,
fontFamily: 'AliBold', overflow: 'hidden',
},
headerIconGlass: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
}, },
tabSwitcher: { tabSwitcher: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -1,11 +1,11 @@
import { LinearGradient } from 'expo-linear-gradient';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { fetchMenstrualFlowSamples } from '@/utils/health'; import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health';
import { import {
buildMenstrualTimeline, buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords, convertHealthKitSamplesToCycleRecords,
@@ -49,7 +49,10 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const loadMenstrualData = async () => { const loadMenstrualData = async () => {
// Avoid setting loading to true for background updates to prevent UI flicker
if (records.length === 0) {
setLoading(true); setLoading(true);
}
try { try {
const today = dayjs(); const today = dayjs();
const startDate = today.subtract(3, 'month').startOf('month').toDate(); const startDate = today.subtract(3, 'month').startOf('month').toDate();
@@ -72,8 +75,16 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
}; };
loadMenstrualData(); loadMenstrualData();
// Listen for data changes
const handleDataChange = () => {
loadMenstrualData();
};
healthDataEvents.on('menstrualDataChanged', handleDataChange);
return () => { return () => {
mounted = false; mounted = false;
healthDataEvents.off('menstrualDataChanged', handleDataChange);
}; };
}, []); }, []);

View File

@@ -1,10 +1,12 @@
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { STATUS_COLORS } from './constants'; import { STATUS_COLORS } from './constants';
import { DayCellProps } from './types'; import { DayCellProps } from './types';
export const DayCell: React.FC<DayCellProps> = ({ cell, isSelected, onPress }) => { export const DayCell: React.FC<DayCellProps> = ({ cell, isSelected, onPress }) => {
const { t } = useTranslation();
const status = cell.info?.status; const status = cell.info?.status;
const colors = status ? STATUS_COLORS[status] : undefined; const colors = status ? STATUS_COLORS[status] : undefined;
@@ -32,7 +34,7 @@ export const DayCell: React.FC<DayCellProps> = ({ cell, isSelected, onPress }) =
{cell.label} {cell.label}
</Text> </Text>
</View> </View>
{cell.isToday && <Text style={styles.todayText}></Text>} {cell.isToday && <Text style={styles.todayText}>{t('menstrual.today')}</Text>}
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -1,7 +1,10 @@
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { InlineTipProps } from './types'; import { InlineTipProps } from './types';
@@ -12,9 +15,12 @@ export const InlineTip: React.FC<InlineTipProps> = ({
onMarkStart, onMarkStart,
onCancelMark, onCancelMark,
}) => { }) => {
const { t, i18n } = useTranslation();
// 14.28% per cell. Center is 7.14%. // 14.28% per cell. Center is 7.14%.
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue; const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
const isFuture = selectedDate.isAfter(dayjs(), 'day'); const isFuture = selectedDate.isAfter(dayjs(), 'day');
const localeKey = i18n.language.startsWith('en') ? 'en' : 'zh-cn';
const dateFormat = t('menstrual.dateFormatShort', { defaultValue: 'M月D日' });
return ( return (
<View style={styles.inlineTipCard}> <View style={styles.inlineTipCard}>
@@ -22,17 +28,19 @@ export const InlineTip: React.FC<InlineTipProps> = ({
<View style={styles.inlineTipRow}> <View style={styles.inlineTipRow}>
<View style={styles.inlineTipDate}> <View style={styles.inlineTipDate}>
<Ionicons name="calendar-outline" size={16} color="#111827" /> <Ionicons name="calendar-outline" size={16} color="#111827" />
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text> <Text style={styles.inlineTipDateText}>
{selectedDate.locale(localeKey).format(dateFormat)}
</Text>
</View> </View>
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && ( {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}> <TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}>
<Ionicons name="add" size={14} color="#fff" /> <Ionicons name="add" size={14} color="#fff" />
<Text style={styles.inlinePrimaryText}></Text> <Text style={styles.inlinePrimaryText}>{t('menstrual.actions.markPeriod')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && ( {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}> <TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}>
<Text style={styles.inlineSecondaryText}></Text> <Text style={styles.inlineSecondaryText}>{t('menstrual.actions.cancelMark')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>

View File

@@ -1,19 +1,21 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { STATUS_COLORS } from './constants'; import { STATUS_COLORS } from './constants';
import { LegendItem } from './types'; import { LegendItem } from './types';
const LEGEND_ITEMS: LegendItem[] = [ export const Legend: React.FC = () => {
{ label: '经期', key: 'period' }, const { t } = useTranslation();
{ label: '预测经期', key: 'predicted-period' }, const legendItems: LegendItem[] = [
{ label: '排卵期', key: 'fertile' }, { label: t('menstrual.legend.period'), key: 'period' },
{ label: '排卵日', key: 'ovulation-day' }, { label: t('menstrual.legend.predictedPeriod'), key: 'predicted-period' },
{ label: t('menstrual.legend.fertile'), key: 'fertile' },
{ label: t('menstrual.legend.ovulation'), key: 'ovulation-day' },
]; ];
export const Legend: React.FC = () => {
return ( return (
<View style={styles.legendRow}> <View style={styles.legendRow}>
{LEGEND_ITEMS.map((item) => ( {legendItems.map((item) => (
<View key={item.key} style={styles.legendItem}> <View key={item.key} style={styles.legendItem}>
<View <View
style={[ style={[

View File

@@ -17,6 +17,7 @@ interface MonthBlockProps {
selectedDateKey: string; selectedDateKey: string;
onSelect: (dateKey: string) => void; onSelect: (dateKey: string) => void;
renderTip: (colIndex: number) => React.ReactNode; renderTip: (colIndex: number) => React.ReactNode;
weekLabels?: string[];
} }
export const MonthBlock: React.FC<MonthBlockProps> = ({ export const MonthBlock: React.FC<MonthBlockProps> = ({
@@ -24,8 +25,10 @@ export const MonthBlock: React.FC<MonthBlockProps> = ({
selectedDateKey, selectedDateKey,
onSelect, onSelect,
renderTip, renderTip,
weekLabels,
}) => { }) => {
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]); const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
const labels = weekLabels?.length === 7 ? weekLabels : WEEK_LABELS;
return ( return (
<View style={styles.monthCard}> <View style={styles.monthCard}>
@@ -34,7 +37,7 @@ export const MonthBlock: React.FC<MonthBlockProps> = ({
<Text style={styles.monthSubtitle}>{month.subtitle}</Text> <Text style={styles.monthSubtitle}>{month.subtitle}</Text>
</View> </View>
<View style={styles.weekRow}> <View style={styles.weekRow}>
{WEEK_LABELS.map((label) => ( {labels.map((label) => (
<Text key={label} style={styles.weekLabel}> <Text key={label} style={styles.weekLabel}>
{label} {label}
</Text> </Text>

View File

@@ -9,7 +9,6 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
Modal, Modal,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
Share, Share,
@@ -19,7 +18,7 @@ import {
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
} from 'react-native'; } from 'react-native';
import ViewShot from 'react-native-view-shot'; import ViewShot, { captureRef } from 'react-native-view-shot';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { import {
@@ -156,7 +155,7 @@ export function WorkoutDetailModal({
type: 'info', type: 'info',
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'), text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
}); });
const uri = await shareContentRef.current.capture?.({ const uri = await captureRef(shareContentRef, {
format: 'png', format: 'png',
quality: 0.95, quality: 0.95,
snapshotContentContainer: true, snapshotContentContainer: true,
@@ -164,6 +163,7 @@ export function WorkoutDetailModal({
if (!uri) { if (!uri) {
throw new Error('share-capture-failed'); 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 shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
const caloriesLabel = metrics?.calories != null const caloriesLabel = metrics?.calories != null
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
@@ -179,7 +179,7 @@ export function WorkoutDetailModal({
await Share.share({ await Share.share({
title: shareTitle, title: shareTitle,
message: shareMessage, message: shareMessage,
url: Platform.OS === 'ios' ? uri : `file://${uri}`, url: shareUri,
}); });
} catch (error) { } catch (error) {
console.warn('workout-detail-share-failed', error); console.warn('workout-detail-share-failed', error);
@@ -487,7 +487,6 @@ export function WorkoutDetailModal({
<ViewShot <ViewShot
ref={shareContentRef} ref={shareContentRef}
style={[styles.sheetContainer, styles.shareCaptureContainer]} style={[styles.sheetContainer, styles.shareCaptureContainer]}
collapsable={false}
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }} options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
> >
<LinearGradient <LinearGradient

View File

@@ -1,5 +1,21 @@
export const menstrual = { export const menstrual = {
dateFormatShort: 'MMM D', dateFormatShort: 'MMM D',
dateFormats: {
monthTitle: 'MMM',
monthSubtitle: 'YYYY',
},
weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
today: 'Today',
legend: {
period: 'Period',
predictedPeriod: 'Predicted period',
fertile: 'Fertile window',
ovulation: 'Ovulation',
},
actions: {
markPeriod: 'Mark period',
cancelMark: 'Cancel',
},
card: { card: {
title: 'Menstrual cycle', title: 'Menstrual cycle',
syncingState: 'Syncing', syncingState: 'Syncing',

View File

@@ -1,5 +1,21 @@
export const menstrual = { export const menstrual = {
dateFormatShort: 'M月D日', dateFormatShort: 'M月D日',
dateFormats: {
monthTitle: 'M月',
monthSubtitle: 'YYYY年',
},
weekdays: ['一', '二', '三', '四', '五', '六', '日'],
today: '今天',
legend: {
period: '经期',
predictedPeriod: '预测经期',
fertile: '排卵期',
ovulation: '排卵日',
},
actions: {
markPeriod: '标记经期',
cancelMark: '取消标记',
},
card: { card: {
title: '生理周期', title: '生理周期',
syncingState: '同步中', syncingState: '同步中',

View File

@@ -271,6 +271,9 @@ class HealthPermissionManager extends SimpleEventEmitter {
// 全局权限管理实例 // 全局权限管理实例
export const healthPermissionManager = new HealthPermissionManager(); export const healthPermissionManager = new HealthPermissionManager();
// 全局健康数据事件发射器
export const healthDataEvents = new SimpleEventEmitter();
// Interface for activity summary data from HealthKit // Interface for activity summary data from HealthKit
export interface HealthActivitySummary { export interface HealthActivitySummary {
activeEnergyBurned: number; activeEnergyBurned: number;
@@ -1632,6 +1635,8 @@ export async function saveMenstrualFlow(
const result = await HealthKitManager.saveMenstrualFlow(options); const result = await HealthKitManager.saveMenstrualFlow(options);
if (result && result.success) { if (result && result.success) {
console.log('经期数据保存成功'); console.log('经期数据保存成功');
// 触发数据变更事件
healthDataEvents.emit('menstrualDataChanged');
return true; return true;
} }
console.error('经期数据保存失败'); console.error('经期数据保存失败');
@@ -1655,6 +1660,8 @@ export async function deleteMenstrualFlow(
const result = await HealthKitManager.deleteMenstrualFlow(options); const result = await HealthKitManager.deleteMenstrualFlow(options);
if (result && result.success) { if (result && result.success) {
console.log(`经期数据删除成功,数量: ${result.count}`); console.log(`经期数据删除成功,数量: ${result.count}`);
// 触发数据变更事件
healthDataEvents.emit('menstrualDataChanged');
return true; return true;
} }
console.error('经期数据删除失败'); console.error('经期数据删除失败');

View File

@@ -1,4 +1,6 @@
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { MenstrualFlowSample } from './health'; import { MenstrualFlowSample } from './health';
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day'; export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
@@ -153,6 +155,9 @@ export const buildMenstrualTimeline = (options?: {
monthsAfter?: number; monthsAfter?: number;
defaultCycleLength?: number; defaultCycleLength?: number;
defaultPeriodLength?: number; defaultPeriodLength?: number;
locale?: 'zh' | 'en';
monthTitleFormat?: string;
monthSubtitleFormat?: string;
}): MenstrualTimeline => { }): MenstrualTimeline => {
const today = dayjs(); const today = dayjs();
const monthsBefore = options?.monthsBefore ?? 2; const monthsBefore = options?.monthsBefore ?? 2;
@@ -267,6 +272,11 @@ export const buildMenstrualTimeline = (options?: {
const months: MenstrualMonth[] = []; const months: MenstrualMonth[] = [];
let monthCursor = startMonth.startOf('month'); 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')) { while (monthCursor.isBefore(endMonth) || monthCursor.isSame(endMonth, 'month')) {
const firstDay = monthCursor.startOf('month'); const firstDay = monthCursor.startOf('month');
const daysInMonth = firstDay.daysInMonth(); const daysInMonth = firstDay.daysInMonth();
@@ -298,10 +308,12 @@ export const buildMenstrualTimeline = (options?: {
}); });
} }
const formattedMonth = firstDay.locale(localeKey);
months.push({ months.push({
id: firstDay.format('YYYY-MM'), id: firstDay.format('YYYY-MM'),
title: firstDay.format('M月'), title: formattedMonth.format(monthTitleFormat),
subtitle: firstDay.format('YYYY年'), subtitle: formattedMonth.format(monthSubtitleFormat),
cells, cells,
}); });