Files
digital-pilates/app/statistics-customization.tsx
richarjiang 17664c679d feat(health): 新增日照时长监测卡片与 HealthKit 集成
- iOS 端集成 HealthKit 日照时间 (TimeInDaylight) 数据获取接口
- 新增 SunlightCard 组件,支持查看今日数据及最近30天历史趋势图表
- 更新统计页和自定义设置页,支持开启/关闭日照卡片
- 优化 HealthDataCard 组件,支持自定义图标组件和副标题显示
- 更新多语言文件及应用版本号至 1.1.6
2025-12-19 17:38:16 +08:00

360 lines
11 KiB
TypeScript

import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useToast } from '@/contexts/ToastContext';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import {
getStatisticsCardOrder,
getStatisticsCardsVisibility,
setStatisticsCardOrder,
setStatisticsCardVisibility,
StatisticsCardsVisibility
} from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useState } from 'react';
import { StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import DraggableFlatList, { RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
type CardItem = {
key: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
visible: boolean;
visibilityKey: keyof StatisticsCardsVisibility;
};
export default function StatisticsCustomizationScreen() {
const safeAreaTop = useSafeAreaTop(60);
const { t } = useI18n();
const { isVip } = useVipService();
const { openMembershipModal } = useMembershipModal();
const { showToast } = useToast();
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<CardItem[]>([]);
const CARD_CONFIG: Record<string, { icon: keyof typeof Ionicons.glyphMap; titleKey: string; visibilityKey: keyof StatisticsCardsVisibility }> = {
mood: { icon: 'happy-outline', titleKey: 'statisticsCustomization.items.mood', visibilityKey: 'showMood' },
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
sunlight: { icon: 'sunny-outline', titleKey: 'statisticsCustomization.items.sunlight', visibilityKey: 'showSunlight' },
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' },
temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' },
menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' },
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },
};
// 加载设置
const loadSettings = useCallback(async () => {
try {
const [visibility, order] = await Promise.all([
getStatisticsCardsVisibility(),
getStatisticsCardOrder(),
]);
// 确保 order 包含所有配置的 key (处理新增 key 的情况)
const allKeys = Object.keys(CARD_CONFIG);
const uniqueOrder = Array.from(new Set([...order, ...allKeys]));
const listData: CardItem[] = uniqueOrder
.filter(key => CARD_CONFIG[key]) // 过滤掉无效 key
.map(key => {
const config = CARD_CONFIG[key];
return {
key,
title: t(config.titleKey),
icon: config.icon,
visible: visibility[config.visibilityKey],
visibilityKey: config.visibilityKey,
};
});
setData(listData);
} catch (error) {
console.error('Failed to load statistics customization settings:', error);
} finally {
setIsLoading(false);
}
}, [t]);
// 页面聚焦时加载设置
useFocusEffect(
useCallback(() => {
loadSettings();
}, [loadSettings])
);
// 处理开关切换
const handleToggle = async (item: CardItem, value: boolean) => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
try {
// 乐观更新 UI
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: value } : d));
await setStatisticsCardVisibility(item.visibilityKey, value);
} catch (error) {
console.error(`Failed to set ${item.key}:`, error);
// 回滚
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: !value } : d));
}
};
// 处理排序结束
const handleDragEnd = async ({ data: newData }: { data: CardItem[] }) => {
setData(newData);
const newOrder = newData.map(item => item.key);
try {
await setStatisticsCardOrder(newOrder);
} catch (error) {
console.error('Failed to save card order:', error);
}
};
const renderItem = useCallback(({ item, drag, isActive }: RenderItemParams<CardItem>) => {
const handleDrag = () => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
drag();
};
return (
<ScaleDecorator>
<TouchableOpacity
onLongPress={handleDrag}
disabled={isActive}
activeOpacity={1}
style={[
styles.rowItem,
isActive && styles.activeItem,
]}
>
<View style={styles.itemContent}>
<View style={styles.leftContent}>
<View style={styles.dragHandle}>
<Ionicons name="reorder-three-outline" size={24} color="#C7C7CC" />
</View>
<View style={styles.iconContainer}>
<Ionicons name={item.icon} size={24} color={'#9370DB'} />
</View>
<Text style={styles.itemTitle}>{item.title}</Text>
</View>
<Switch
value={item.visible}
onValueChange={(v) => handleToggle(item, v)}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
</TouchableOpacity>
</ScaleDecorator>
);
}, [handleToggle, isVip, t, showToast, openMembershipModal]);
if (isLoading) {
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
</View>
</View>
);
}
return (
<GestureHandlerRootView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<HeaderBar
title={t('statisticsCustomization.title')}
onBack={() => router.back()}
/>
<DraggableFlatList
data={data}
onDragEnd={handleDragEnd}
keyExtractor={(item) => item.key}
renderItem={renderItem}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
ListHeaderComponent={() => (
<>
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('statisticsCustomization.description.text')}
</Text>
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('statisticsCustomization.sectionTitle')}</Text>
</View>
</>
)}
/>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: '#666',
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
headerSection: {
marginBottom: 20,
},
subtitle: {
fontSize: 14,
color: '#6C757D',
marginBottom: 12,
marginLeft: 4,
},
descriptionCard: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: 'rgba(147, 112, 219, 0.1)',
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
descriptionText: {
flex: 1,
fontSize: 13,
color: '#2C3E50',
lineHeight: 18,
},
sectionHeader: {
marginBottom: 12,
marginLeft: 4,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
},
rowItem: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 4,
elevation: 2,
},
activeItem: {
backgroundColor: '#FAFAFA',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
zIndex: 100,
transform: [{ scale: 1.02 }],
},
itemContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
height: 72,
},
leftContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
dragHandle: {
paddingRight: 12,
},
iconContainer: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(147, 112, 219, 0.05)',
borderRadius: 12,
marginRight: 12,
},
itemTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
flex: 1,
},
switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
});