Files
digital-pilates/app/(tabs)/personal.tsx
richarjiang f4ce3d9edf feat(medications): 重构药品通知系统并添加独立设置页面
- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消
- 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制
- 重构药品详情页面,移除频率编辑功能到独立页面
- 优化药品添加流程,支持拍照和相册选择图片
- 改进通知权限检查和错误处理机制
- 更新用户偏好设置,添加药品提醒开关配置
2025-11-11 16:43:27 +08:00

775 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ActivityHeatMap from '@/components/ActivityHeatMap';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore';
import { log } from '@/utils/logger';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const insets = useSafeAreaInsets();
const isLgAvaliable = isLiquidGlassAvailable()
// 推送通知相关
const {
requestPermission,
sendNotification,
} = useNotifications();
// 移除 notificationEnabled 状态,因为现在在通知设置页面中管理
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
const clickTimestamps = useRef<number[]>([]);
const clickTimeoutRef = useRef<number | null>(null);
const handleMembershipPress = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) {
return;
}
openMembershipModal();
}, [ensureLoggedIn, openMembershipModal]);
// 计算底部间距
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
}, [insets?.bottom]);
// 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile);
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
// 页面聚焦时获取最新用户信息
useFocusEffect(
React.useCallback(() => {
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
// 加载开发者模式状态
loadDeveloperModeState();
}, [dispatch])
);
// 移除 loadNotificationPreference 函数,因为已移到通知设置页面
// 加载开发者模式状态
const loadDeveloperModeState = async () => {
try {
const enabled = await getItem('developer_mode_enabled');
if (enabled === 'true') {
setShowDeveloperSection(true);
}
} catch (error) {
console.error('加载开发者模式状态失败:', error);
}
};
// 保存开发者模式状态
const saveDeveloperModeState = async (enabled: boolean) => {
try {
await setItem('developer_mode_enabled', enabled.toString());
} catch (error) {
console.error('保存开发者模式状态失败:', error);
}
};
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
// 初始化时只加载开发者模式状态
useEffect(() => {
loadDeveloperModeState();
}, []);
// 处理用户名连续点击
const handleUserNamePress = () => {
const now = Date.now();
clickTimestamps.current.push(now);
// 清除之前的超时
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
// 只保留最近1秒内的点击
clickTimestamps.current = clickTimestamps.current.filter(timestamp => now - timestamp <= 1000);
// 检查是否有3次连续点击
if (clickTimestamps.current.length >= 3) {
setShowDeveloperSection(true);
saveDeveloperModeState(true); // 持久化保存开发者模式状态
clickTimestamps.current = []; // 清空点击记录
log.info('开发者模式已激活');
} else {
// 1秒后清空点击记录
clickTimeoutRef.current = setTimeout(() => {
clickTimestamps.current = [];
}, 1000);
}
};
// 移除 handleNotificationToggle 函数,因为已移到通知设置页面
// 用户信息头部
const UserHeader = () => (
<View style={[styles.sectionContainer, {
marginBottom: 0
}]}>
<View style={[styles.userInfoContainer,]}>
<View style={styles.avatarContainer}>
<Image
source={userProfile.avatar || DEFAULT_AVATAR_URL}
style={styles.avatar}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</View>
<View style={styles.userDetails}>
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
{userProfile.memberNumber && (
<Text style={styles.userMemberNumber}>: {userProfile.memberNumber}</Text>
)}
{userProfile.freeUsageCount !== undefined && (
<View style={styles.aiUsageContainer}>
<Ionicons name="sparkles-outline" size={12} color="#9370DB" />
<Text style={styles.aiUsageText}>
AI次数: {userProfile.isVip ? '无限' : userProfile.freeUsageCount}
</Text>
</View>
)}
</View>
{isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass}>
<Text style={styles.editButtonTextGlass}>{isLoggedIn ? '编辑' : '登录'}</Text>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</TouchableOpacity>
)}
</View>
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
.map((key) => fallbackProfile[key])
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
const rawExpireDate = userProfile.membershipExpiration
let formattedExpire = '长期有效';
if (typeof rawExpireDate === 'string' && rawExpireDate.trim().length > 0) {
const parsed = dayjs(rawExpireDate);
formattedExpire = parsed.isValid() ? parsed.format('YYYY年MM月DD日') : rawExpireDate;
}
const planName =
activeMembershipPlanName?.trim() ||
userProfile.vipPlanName?.trim() ||
'VIP 会员';
return (
<View style={styles.sectionContainer}>
<LinearGradient
colors={['#5B4CFF', '#8D5BEA']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.vipCard}
>
<View style={styles.vipCardDecorationLarge} />
<View style={styles.vipCardDecorationSmall} />
<View style={styles.vipCardHeader}>
<View style={styles.vipCardHeaderLeft}>
<View style={styles.vipBadge}>
<Ionicons name="sparkles-outline" size={16} color="#FFD361" />
<Text style={styles.vipBadgeText}></Text>
</View>
<Text style={styles.vipCardTitle}>{planName}</Text>
</View>
<View style={styles.vipCardIllustration}>
<Ionicons name="ribbon" size={32} color="rgba(255,255,255,0.88)" />
</View>
</View>
<View style={styles.vipCardFooter}>
<View style={styles.vipExpiryInfo}>
<Text style={styles.vipExpiryLabel}></Text>
<View style={styles.vipExpiryRow}>
<Ionicons name="time-outline" size={16} color="rgba(255,255,255,0.85)" />
<Text style={styles.vipExpiryValue}>{formattedExpire}</Text>
</View>
</View>
<TouchableOpacity
style={styles.vipChangeButton}
activeOpacity={0.85}
onPress={() => {
void handleMembershipPress();
}}
>
<Ionicons name="swap-horizontal-outline" size={16} color="#2F1767" />
<Text style={styles.vipChangeButtonText}></Text>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
);
};
// 数据统计部分
const StatsSection = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'unset'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
</View>
</View>
);
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.cardContainer}>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={[styles.menuItem, index === items.length - 1 && { borderBottomWidth: 0 }]}
onPress={item.type === 'switch' ? undefined : item.onPress}
disabled={item.type === 'switch'}
>
<View style={styles.menuItemLeft}>
<View style={[
styles.iconContainer,
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
]}>
<Ionicons
name={item.icon}
size={20}
color={item.isDanger ? '#FF4444' : '#9370DB'}
/>
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
value={item.switchValue || false}
onValueChange={item.onSwitchChange || (() => { })}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
)}
</TouchableOpacity>
))}
</View>
</View>
);
// 菜单项配置
const menuSections = [
{
title: '通知',
items: [
{
icon: 'notifications-outline' as const,
title: '通知设置',
onPress: () => pushIfAuthedElseLogin(ROUTES.NOTIFICATION_SETTINGS),
}
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: '开发者',
items: [
{
icon: 'code-slash-outline' as const,
title: '开发者选项',
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
},
{
icon: 'settings-outline' as const,
title: '推送通知设置',
onPress: () => pushIfAuthedElseLogin('/push-notification-settings'),
},
],
}] : []),
{
title: '其他',
items: [
{
icon: 'shield-checkmark-outline' as const,
title: '隐私政策',
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
},
{
icon: 'chatbubble-ellipses-outline' as const,
title: '意见反馈',
onPress: () => Linking.openURL('mailto:richardwei1995@gmail.com'),
},
{
icon: 'document-text-outline' as const,
title: '用户协议',
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
},
],
},
// 只有登录用户才显示账号与安全菜单
...(isLoggedIn ? [{
title: '账号与安全',
items: [
{
icon: 'log-out-outline' as const,
title: '退出登录',
onPress: confirmLogout,
isDanger: false,
},
{
icon: 'trash-outline' as const,
title: '注销帐号',
onPress: confirmDeleteAccount,
isDanger: true,
},
],
}] : []),
];
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: bottomPadding,
paddingHorizontal: 16,
}}
showsVerticalScrollIndicator={false}
>
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
<View style={styles.fishRecordContainer}>
{/* <Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
contentFit="cover"
style={{ width: 16, height: 16, marginLeft: 6 }}
transition={200}
cachePolicy="memory-disk"
/> */}
<Text style={styles.fishRecordText}></Text>
</View>
<ActivityHeatMap />
{menuSections.map((section, index) => (
<MenuSection key={index} title={section.title} items={section.items} />
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
scrollView: {
flex: 1,
},
// 部分容器
sectionContainer: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 10,
paddingHorizontal: 4,
},
// 卡片容器
cardContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: {
borderRadius: 20,
padding: 20,
overflow: 'hidden',
shadowColor: '#4C3AFF',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 6,
},
vipCardDecorationLarge: {
position: 'absolute',
right: -40,
top: -30,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.12)',
},
vipCardDecorationSmall: {
position: 'absolute',
left: -30,
bottom: -30,
width: 140,
height: 140,
borderRadius: 70,
backgroundColor: 'rgba(255,255,255,0.08)',
},
vipCardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
vipCardHeaderLeft: {
flex: 1,
paddingRight: 16,
},
vipBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.18)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 14,
},
vipBadgeText: {
color: '#FFD361',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
},
vipCardTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
marginTop: 12,
},
vipCardSubtitle: {
color: 'rgba(255,255,255,0.88)',
fontSize: 12,
lineHeight: 18,
marginTop: 6,
},
vipCardIllustration: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255,255,255,0.18)',
alignItems: 'center',
justifyContent: 'center',
},
vipCardFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
},
vipExpiryInfo: {
flex: 1,
},
vipExpiryLabel: {
color: 'rgba(255,255,255,0.72)',
fontSize: 12,
marginBottom: 6,
},
vipExpiryRow: {
flexDirection: 'row',
alignItems: 'center',
},
vipExpiryValue: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600',
marginLeft: 6,
},
vipChangeButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
vipChangeButtonText: {
color: '#2F1767',
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
// 用户信息区域
userInfoContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
},
avatarContainer: {
marginRight: 12,
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
borderWidth: 2,
borderColor: '#9370DB',
},
userDetails: {
flex: 1,
},
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 4,
},
userRole: {
fontSize: 14,
color: '#9370DB',
fontWeight: '500',
},
userMemberNumber: {
fontSize: 10,
color: '#6C757D',
marginTop: 4,
},
aiUsageContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
aiUsageText: {
fontSize: 10,
color: '#9370DB',
marginLeft: 2,
fontWeight: '500',
},
editButton: {
backgroundColor: '#9370DB',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
},
editButtonGlass: {
backgroundColor: '#ffffff',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
editButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)',
fontSize: 14,
fontWeight: '600',
},
// 数据统计
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
padding: 16,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statValue: {
fontSize: 18,
fontWeight: 'bold',
color: '#9370DB',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#6C757D',
fontWeight: '500',
},
// 菜单项
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemText: {
fontSize: 15,
color: '#2C3E50',
fontWeight: '500',
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
fishRecordContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
marginBottom: 10,
},
fishRecordText: {
fontSize: 16,
fontWeight: 'bold',
color: '#2C3E50',
marginLeft: 4,
},
});