Files
mindfulness/client/components/home/ProfileModal.tsx
2026-01-29 19:09:36 +08:00

663 lines
17 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 React, { useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient';
import { Switch } from 'react-native';
import Animated, {
Easing,
FadeIn,
FadeOut,
SlideInLeft,
SlideInRight,
SlideOutLeft,
SlideOutRight,
} from 'react-native-reanimated';
import SheetModal from '@/components/ui/SheetModal';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import {
getDailyReminderSettings,
getFavorites,
setDailyReminderSettings,
type DailyReminderSettings,
} from '@/src/storage/appStorage';
import AvatarIcon from '@/assets/images/home/Profile/Default_avatar.png';
import MyLikeIcon from '@/assets/images/home/Profile/mylike.svg';
import SmallComponentIcon from '@/assets/images/home/Profile/small_component.svg';
import RemindIcon from '@/assets/images/home/Profile/remind.svg';
import PrivacyIcon from '@/assets/images/home/Profile/privacy Policy.svg';
import TermsIcon from '@/assets/images/home/Profile/terms_of_use.svg';
import LanguageIcon from '@/assets/images/home/Profile/language.svg';
type Props = {
visible: boolean;
name?: string;
onClose: () => void;
};
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget';
type NavDirection = 'forward' | 'back';
export default function ProfileModal({ visible, name, onClose }: Props) {
const { t } = useTranslation();
const [page, setPage] = useState<Page>('root');
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
const isRoot = page === 'root';
// 关闭弹窗时重置为首页,避免下次打开停留在二级页
useEffect(() => {
if (!visible) {
setNavDirection('back');
setPage('root');
}
}, [visible]);
function go(next: Page, direction: NavDirection) {
setNavDirection(direction);
setPage(next);
}
function handleClose() {
setNavDirection('back');
setPage('root');
onClose();
}
function handleSheetClose() {
// 需求:二级页点击 X 等同于点击“返回”
if (!isRoot) {
go('root', 'back');
return;
}
handleClose();
}
const title = useMemo(() => {
if (page === 'favorites') return t('profile.favorites');
if (page === 'dailyReminder') return t('dailyReminder.title');
if (page === 'widget') return t('profile.widget');
return t('profile.title');
}, [page, t]);
const transition = useMemo(() => {
const duration = 220;
const easing = Easing.out(Easing.cubic);
// 进入二级页:从右侧滑入;返回:从左侧滑入
const entering =
navDirection === 'forward'
? SlideInRight.duration(duration).easing(easing)
: SlideInLeft.duration(duration).easing(easing);
// 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出
const exiting =
navDirection === 'forward'
? SlideOutLeft.duration(duration).easing(easing)
: SlideOutRight.duration(duration).easing(easing);
return {
entering,
exiting,
fadeIn: FadeIn.duration(duration).easing(easing),
fadeOut: FadeOut.duration(duration).easing(easing),
};
}, [navDirection]);
return (
<SheetModal visible={visible} title={title} onClose={handleSheetClose}>
{!isRoot && (
<Pressable
onPress={() => go('root', 'back')}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back')}
style={styles.backRow}
>
<Text style={styles.backText}> {t('common.back')}</Text>
</Pressable>
)}
<Animated.View
key={page}
entering={transition.entering}
exiting={transition.exiting}
style={styles.pageWrap}
>
<Animated.View entering={transition.fadeIn} exiting={transition.fadeOut}>
{page === 'root' ? (
<RootPage
name={name}
onOpenFavorites={() => go('favorites', 'forward')}
onOpenWidget={() => go('widget', 'forward')}
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
/>
) : page === 'favorites' ? (
<FavoritesPage visible={visible} />
) : page === 'dailyReminder' ? (
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
) : (
<WidgetPage />
)}
</Animated.View>
</Animated.View>
</SheetModal>
);
}
function toastTodo(t: (key: string) => string) {
Alert.alert(t('profile.todoTitle'), t('profile.todoDesc'));
}
function RootPage({
name,
onOpenFavorites,
onOpenWidget,
onOpenDailyReminder,
}: {
name?: string;
onOpenFavorites: () => void;
onOpenWidget: () => void;
onOpenDailyReminder: () => void;
}) {
const { t } = useTranslation();
return (
<>
<View style={styles.header}>
<Image source={AvatarIcon} style={styles.avatarImage} resizeMode="contain" />
<Text style={styles.name}>{name || 'Hali'}</Text>
</View>
<View style={styles.quickRow}>
<QuickCard title={t('profile.favorites')} onPress={onOpenFavorites}>
<MyLikeIcon width={34} height={34} />
</QuickCard>
<QuickCard title={t('profile.widget')} onPress={onOpenWidget}>
<SmallComponentIcon width={34} height={34} />
</QuickCard>
</View>
<View style={styles.list}>
<ListItem
icon={<RemindIcon width={22} height={22} />}
title={t('profile.dailyReminder')}
onPress={onOpenDailyReminder}
/>
<ListItem
icon={<PrivacyIcon width={22} height={22} />}
title={t('profile.privacy')}
onPress={() => toastTodo(t)}
/>
<ListItem
icon={<TermsIcon width={22} height={22} />}
title={t('profile.terms')}
onPress={() => toastTodo(t)}
/>
<ListItem
icon={<LanguageIcon width={22} height={22} />}
title={t('profile.language')}
onPress={() => toastTodo(t)}
/>
</View>
</>
);
}
function FavoritesPage({ visible }: { visible: boolean }) {
const { t } = useTranslation();
const [ids, setIds] = useState<string[]>([]);
// 每次进入该页刷新一次,确保展示最新“喜欢”
useEffect(() => {
if (!visible) return;
let cancelled = false;
(async () => {
const list = await getFavorites();
if (!cancelled) setIds(list);
})();
return () => {
cancelled = true;
};
}, [visible]);
const items = useMemo(() => {
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
}, [ids]);
return (
<View style={styles.favContainer}>
{items.length === 0 ? (
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
) : (
<FlatList
data={items}
keyExtractor={(it) => it.id}
contentContainerStyle={styles.favList}
renderItem={({ item }) => (
<View style={styles.favRow}>
<Text style={styles.favText}>{item.text}</Text>
</View>
)}
/>
)}
</View>
);
}
function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [timesPerDay, setTimesPerDay] = useState(3);
const [pushEnabled, setPushEnabled] = useState(false);
useEffect(() => {
let cancelled = false;
if (!visible) return;
setLoading(true);
(async () => {
const s = await getDailyReminderSettings();
if (cancelled) return;
setTimesPerDay(s.timesPerDay);
setPushEnabled(s.pushEnabled);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [visible]);
function clamp(next: number) {
return Math.min(10, Math.max(1, next));
}
async function onOk() {
if (loading) return;
setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
await setDailyReminderSettings(next);
setLoading(false);
onDone();
}
return (
<>
<View style={styles.counter}>
<Pressable
onPress={() => setTimesPerDay((v) => clamp(v - 1))}
hitSlop={10}
style={styles.circleBtn}
accessibilityRole="button"
accessibilityLabel={t('dailyReminder.minus')}
>
<Text style={styles.circleText}></Text>
</Pressable>
<View style={styles.countCenter}>
<Text style={styles.countNumber}>{timesPerDay}</Text>
<Text style={styles.countUnit}>{t('dailyReminder.timesUnit')}</Text>
</View>
<Pressable
onPress={() => setTimesPerDay((v) => clamp(v + 1))}
hitSlop={10}
style={styles.circleBtn}
accessibilityRole="button"
accessibilityLabel={t('dailyReminder.plus')}
>
<Text style={styles.circleText}></Text>
</Pressable>
</View>
<View style={styles.remindRow}>
<View style={styles.rowLeft}>
<View style={styles.rowIcon}>
<RemindIcon width={18} height={18} />
</View>
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
</View>
<Switch value={pushEnabled} onValueChange={setPushEnabled} />
</View>
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
<LinearGradient
colors={['#F69F7B', '#F99CC0']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.okBtn, loading && styles.okBtnDisabled]}
>
<Text style={styles.okText}>{t('dailyReminder.ok')}</Text>
</LinearGradient>
</Pressable>
</>
);
}
function WidgetPage() {
const { t } = useTranslation();
return (
<View style={styles.widgetContent}>
<View style={styles.widgetRow}>
<View style={styles.widgetCard}>
<View style={[styles.widgetPreviewInner, styles.widgetPreviewLock]}>
<Text style={styles.widgetPreviewDate} numberOfLines={1}>
{t('widget.previewDate')}
</Text>
<Text style={styles.widgetPreviewTime} numberOfLines={1}>
13:45
</Text>
</View>
<Text style={styles.widgetCardLabel}>{t('widget.lockScreen')}</Text>
</View>
<View style={styles.widgetCard}>
<View style={[styles.widgetPreviewInner, styles.widgetPreviewHome]}>
<Text style={styles.widgetPreviewQuote} numberOfLines={3}>
{t('widget.previewQuote')}
</Text>
</View>
<Text style={styles.widgetCardLabel}>{t('widget.homeScreen')}</Text>
</View>
</View>
<Text style={styles.widgetDesc}>{t('settings.widgetDesc')}</Text>
</View>
);
}
function QuickCard({
title,
onPress,
children,
}: {
title: string;
onPress: () => void;
children: React.ReactNode;
}) {
return (
<Pressable onPress={onPress} style={styles.quickCard} hitSlop={6}>
<Text style={styles.quickTitle}>{title}</Text>
<View style={styles.quickIconWrap}>{children}</View>
</Pressable>
);
}
function ListItem({
icon,
title,
onPress,
}: {
icon: React.ReactNode;
title: string;
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} style={styles.item} hitSlop={6}>
<View style={styles.itemLeft}>
<View style={styles.itemIcon}>{icon}</View>
<Text style={styles.itemText}>{title}</Text>
</View>
<Text style={styles.chevron}></Text>
</Pressable>
);
}
const styles = StyleSheet.create({
pageWrap: {
// 给页面切换动画一个稳定的容器,避免布局抖动
},
backRow: {
alignSelf: 'flex-start',
paddingVertical: 4,
paddingHorizontal: 2,
marginBottom: 6,
},
backText: {
color: 'rgba(94,42,40,0.75)',
fontSize: 13,
fontWeight: '700',
},
header: {
alignItems: 'center',
paddingTop: 6,
paddingBottom: 14,
},
// 头像是默认图片234x183没有圆角/边框/背景颜色
avatarImage: {
width: 234,
height: 183,
marginBottom: 10,
},
name: {
color: '#5E2A28',
fontSize: 18,
fontWeight: '700',
},
quickRow: {
flexDirection: 'row',
gap: 12,
paddingBottom: 14,
},
quickCard: {
flex: 1,
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(244,214,194,0.65)',
},
quickTitle: {
color: '#5E2A28',
fontSize: 14,
fontWeight: '700',
marginBottom: 10,
},
quickIconWrap: { alignItems: 'center', justifyContent: 'center', flex: 1 },
list: {
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.75)',
overflow: 'hidden',
marginBottom: 8,
},
item: {
height: 52,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(94,42,40,0.10)',
},
itemLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
itemIcon: {
width: 28,
height: 28,
borderRadius: 10,
backgroundColor: 'rgba(244,214,194,0.55)',
alignItems: 'center',
justifyContent: 'center',
},
itemText: {
color: '#5E2A28',
fontSize: 15,
fontWeight: '600',
},
chevron: {
color: 'rgba(94,42,40,0.45)',
fontSize: 20,
marginTop: -2,
},
favContainer: {
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.75)',
overflow: 'hidden',
marginBottom: 8,
},
favEmpty: {
color: 'rgba(94,42,40,0.55)',
fontSize: 15,
textAlign: 'center',
paddingVertical: 26,
paddingHorizontal: 14,
},
favList: {
paddingBottom: 10,
},
favRow: {
paddingHorizontal: 14,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(94,42,40,0.10)',
backgroundColor: 'rgba(255,255,255,0.15)',
},
favText: {
color: '#5E2A28',
fontSize: 15,
lineHeight: 22,
fontWeight: '600',
},
counter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 22,
paddingTop: 8,
paddingBottom: 22,
},
circleBtn: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: 'rgba(244,214,194,0.75)',
alignItems: 'center',
justifyContent: 'center',
},
circleText: {
color: '#5E2A28',
fontSize: 20,
fontWeight: '700',
marginTop: -1,
},
countCenter: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 6,
},
countNumber: {
color: '#111',
fontSize: 54,
fontWeight: '800',
letterSpacing: -1,
},
countUnit: {
color: '#111',
fontSize: 16,
fontWeight: '700',
marginBottom: 10,
},
remindRow: {
height: 54,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.75)',
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 18,
},
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
rowIcon: {
width: 28,
height: 28,
borderRadius: 10,
backgroundColor: 'rgba(244,214,194,0.55)',
alignItems: 'center',
justifyContent: 'center',
},
rowText: {
color: '#5E2A28',
fontSize: 15,
fontWeight: '600',
},
okPressable: {
marginBottom: 6,
},
okBtn: {
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
okBtnDisabled: { opacity: 0.7 },
okText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
widgetContent: {
paddingBottom: 18,
gap: 14,
},
widgetRow: {
flexDirection: 'row',
gap: 12,
},
widgetCard: {
flex: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.75)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(94,42,40,0.12)',
},
widgetPreviewInner: {
height: 120,
padding: 12,
justifyContent: 'center',
},
widgetPreviewLock: {
backgroundColor: 'rgba(244,214,194,0.65)',
},
widgetPreviewHome: {
backgroundColor: 'rgba(243,208,225,0.55)',
},
widgetPreviewDate: {
color: 'rgba(94,42,40,0.70)',
fontSize: 12,
fontWeight: '600',
marginBottom: 6,
},
widgetPreviewTime: {
color: '#ffffff',
fontSize: 44,
fontWeight: '800',
letterSpacing: 1,
},
widgetPreviewQuote: {
color: '#5E2A28',
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
},
widgetCardLabel: {
paddingVertical: 10,
textAlign: 'center',
color: '#5E2A28',
fontSize: 13,
fontWeight: '700',
},
widgetDesc: {
color: 'rgba(94,42,40,0.75)',
fontSize: 13,
lineHeight: 18,
},
});