663 lines
17 KiB
TypeScript
663 lines
17 KiB
TypeScript
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,
|
||
},
|
||
});
|
||
|