feat(i18n): 增强生理周期模块的国际化支持,添加多语言格式和翻译
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '同步中',
|
||||||
|
|||||||
@@ -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('经期数据删除失败');
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user