feat(app): 新增生理周期记录功能与首页卡片自定义支持
- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测 - 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序 - 重构首页统计页面布局逻辑,支持动态渲染与混合布局 - 引入 react-native-draggable-flatlist 用于实现拖拽排序功能 - 添加相关多语言配置及用户偏好设置存储接口
This commit is contained in:
357
app/statistics-customization.tsx
Normal file
357
app/statistics-customization.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
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' },
|
||||
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' },
|
||||
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 }],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user