1079 lines
30 KiB
TypeScript
1079 lines
30 KiB
TypeScript
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Dimensions } from 'react-native';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { Switch } from 'react-native';
|
||
import * as WebBrowser from 'expo-web-browser';
|
||
import Animated, {
|
||
Easing,
|
||
FadeIn,
|
||
FadeOut,
|
||
SlideInLeft,
|
||
SlideInRight,
|
||
SlideOutLeft,
|
||
SlideOutRight,
|
||
Layout,
|
||
} from 'react-native-reanimated';
|
||
|
||
import SheetModal from '@/components/ui/SheetModal';
|
||
|
||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||
import {
|
||
getDailyReminderSettings,
|
||
getFavorites,
|
||
setDailyReminderSettings,
|
||
removeFavorite,
|
||
getRecoFeedCache,
|
||
getUserProfile,
|
||
type DailyReminderSettings,
|
||
type FavoriteItem,
|
||
} from '@/src/storage/appStorage';
|
||
|
||
import AvatarIcon from '@/assets/images/home/Profile/Default_avatar.svg';
|
||
import MyLikeIcon from '@/assets/images/icon/mylike_icon.svg';
|
||
import SmallComponentIcon from '@/assets/images/icon/weight_icon.svg';
|
||
import RemindIcon from '@/assets/images/icon/Push_icon.svg';
|
||
import PrivacyIcon from '@/assets/images/icon/privacy_icon.svg';
|
||
import TermsIcon from '@/assets/images/icon/Terms_icon.svg';
|
||
import LanguageIcon from '@/assets/images/icon/language_icon.svg';
|
||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
|
||
import * as Notifications from 'expo-notifications';
|
||
import { changeLanguage } from '@/src/i18n';
|
||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||
|
||
const { width } = Dimensions.get('window');
|
||
|
||
type Props = {
|
||
visible: boolean;
|
||
name?: string;
|
||
onClose: () => void;
|
||
};
|
||
|
||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
||
type NavDirection = 'forward' | 'back';
|
||
|
||
const NATURE_IMAGES = [
|
||
require('@/assets/theme/nature/1.png'),
|
||
require('@/assets/theme/nature/2.png'),
|
||
require('@/assets/theme/nature/3.png'),
|
||
require('@/assets/theme/nature/4.png'),
|
||
require('@/assets/theme/nature/5.png'),
|
||
require('@/assets/theme/nature/6.png'),
|
||
require('@/assets/theme/nature/7.png'),
|
||
require('@/assets/theme/nature/8.png'),
|
||
require('@/assets/theme/nature/9.png'),
|
||
require('@/assets/theme/nature/10.png'),
|
||
require('@/assets/theme/nature/11.png'),
|
||
require('@/assets/theme/nature/12.png'),
|
||
require('@/assets/theme/nature/13.png'),
|
||
require('@/assets/theme/nature/14.png'),
|
||
require('@/assets/theme/nature/15.png'),
|
||
require('@/assets/theme/nature/17.png'),
|
||
require('@/assets/theme/nature/18.png'),
|
||
require('@/assets/theme/nature/19.png'),
|
||
require('@/assets/theme/nature/20.png'),
|
||
require('@/assets/theme/nature/22.png'),
|
||
];
|
||
|
||
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
|
||
const { t } = useTranslation();
|
||
|
||
const [page, setPage] = useState<Page>('root');
|
||
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
||
const [currentName, setCurrentName] = useState(propName);
|
||
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||
const isRoot = page === 'root';
|
||
|
||
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||
useEffect(() => {
|
||
if (visible) {
|
||
getUserProfile().then(profile => {
|
||
if (profile.name) {
|
||
setCurrentName(profile.name);
|
||
}
|
||
});
|
||
|
||
// 打开弹窗时拉取协议链接(由后端按语言下发;默认 EN)
|
||
fetchLegalLinks()
|
||
.then((res) => {
|
||
setLegalLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
||
})
|
||
.catch((e) => {
|
||
if (__DEV__) console.log('[LegalLinks] 拉取失败(ProfileModal):', e);
|
||
setLegalLinks({});
|
||
});
|
||
} else {
|
||
setNavDirection('back');
|
||
setPage('root');
|
||
}
|
||
}, [visible]);
|
||
|
||
// 同步外部 propName 的变化
|
||
useEffect(() => {
|
||
if (propName) {
|
||
setCurrentName(propName);
|
||
}
|
||
}, [propName]);
|
||
|
||
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 openLink = useCallback(
|
||
async (url?: string) => {
|
||
if (!url) {
|
||
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
|
||
return;
|
||
}
|
||
try {
|
||
await WebBrowser.openBrowserAsync(url);
|
||
} catch (error) {
|
||
Alert.alert(t('common.error'), t('common.openLinkError'));
|
||
}
|
||
},
|
||
[t],
|
||
);
|
||
|
||
const title = useMemo(() => {
|
||
if (page === 'favorites') return t('profile.favorites');
|
||
if (page === 'dailyReminder') return t('dailyReminder.title');
|
||
if (page === 'widget') return t('profile.widget');
|
||
if (page === 'language') return t('profile.language');
|
||
if (page === 'widgetHowTo') return t('widget.howToTitle');
|
||
return t('profile.title');
|
||
}, [page, t]);
|
||
|
||
const transition = useMemo(() => {
|
||
const duration = 220;
|
||
const easing = Easing.out(Easing.cubic);
|
||
|
||
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
|
||
const entering = FadeIn.duration(duration).easing(easing);
|
||
const exiting = FadeOut.duration(duration).easing(easing);
|
||
|
||
return {
|
||
entering,
|
||
exiting,
|
||
};
|
||
}, [navDirection]);
|
||
|
||
return (
|
||
<SheetModal
|
||
visible={visible}
|
||
title={title}
|
||
onClose={handleSheetClose}
|
||
leftIcon={!isRoot ? require('@/assets/images/icon/back_icon.png') : undefined}
|
||
>
|
||
<View style={[styles.pageWrap, !isRoot && { flex: 1 }]}>
|
||
<Animated.View
|
||
key={page}
|
||
entering={transition.entering}
|
||
exiting={transition.exiting}
|
||
style={!isRoot ? { flex: 1 } : undefined}
|
||
>
|
||
{page === 'root' ? (
|
||
<RootPage
|
||
name={currentName}
|
||
onOpenFavorites={() => go('favorites', 'forward')}
|
||
onOpenWidget={() => go('widget', 'forward')}
|
||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||
onOpenLanguage={() => go('language', 'forward')}
|
||
onOpenPrivacy={() => openLink(legalLinks.privacy)}
|
||
onOpenTerms={() => openLink(legalLinks.terms)}
|
||
/>
|
||
) : page === 'favorites' ? (
|
||
<FavoritesPage visible={visible} page={page} />
|
||
) : page === 'dailyReminder' ? (
|
||
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
|
||
) : page === 'language' ? (
|
||
<LanguagePage />
|
||
) : page === 'widgetHowTo' ? (
|
||
<WidgetHowToPage />
|
||
) : (
|
||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||
)}
|
||
</Animated.View>
|
||
</View>
|
||
</SheetModal>
|
||
);
|
||
}
|
||
|
||
function toastTodo(t: (key: string) => string) {
|
||
Alert.alert(t('profile.todoTitle'), t('profile.todoDesc'));
|
||
}
|
||
|
||
function RootPage({
|
||
name,
|
||
onOpenFavorites,
|
||
onOpenWidget,
|
||
onOpenDailyReminder,
|
||
onOpenLanguage,
|
||
onOpenPrivacy,
|
||
onOpenTerms,
|
||
}: {
|
||
name?: string;
|
||
onOpenFavorites: () => void;
|
||
onOpenWidget: () => void;
|
||
onOpenDailyReminder: () => void;
|
||
onOpenLanguage: () => void;
|
||
onOpenPrivacy: () => void;
|
||
onOpenTerms: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<>
|
||
<View style={styles.header}>
|
||
<AvatarIcon width={234} height={183} />
|
||
<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={onOpenPrivacy}
|
||
/>
|
||
<ListItem
|
||
icon={<TermsIcon width={22} height={22} />}
|
||
title={t('profile.terms')}
|
||
onPress={onOpenTerms}
|
||
/>
|
||
<ListItem
|
||
icon={<LanguageIcon width={22} height={22} />}
|
||
title={t('profile.language')}
|
||
onPress={onOpenLanguage}
|
||
/>
|
||
</View>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||
const { t } = useTranslation();
|
||
const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!visible) return;
|
||
refreshFavorites();
|
||
}, [visible, page]); // 当页面切换回 favorites 时也刷新一次
|
||
|
||
async function refreshFavorites() {
|
||
const storedFavs = await getFavorites();
|
||
const textMap = new Map<string, string>();
|
||
|
||
// 1) Mock 文案
|
||
MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey)));
|
||
|
||
// 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案)
|
||
const cache = await getRecoFeedCache();
|
||
cache?.items?.forEach((c) => textMap.set(String(c.content_id), c.text));
|
||
|
||
// 3) 组装:优先使用收藏时写入的 text,其次从 map 回填
|
||
const list = storedFavs.map((fav) => ({
|
||
...fav,
|
||
text: fav.text || textMap.get(String(fav.id)) || t('favorites.unknownText'),
|
||
}));
|
||
|
||
setFavorites(list);
|
||
}
|
||
|
||
async function handleRemove(favId: string) {
|
||
// 1. 调用存储层移除收藏
|
||
await removeFavorite(favId);
|
||
// 2. 更新本地状态
|
||
setFavorites(prev => prev.filter(item => item.favId !== favId));
|
||
}
|
||
|
||
return (
|
||
<View style={styles.favContainer}>
|
||
{favorites.length === 0 ? (
|
||
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
|
||
) : (
|
||
<FlatList
|
||
data={favorites}
|
||
keyExtractor={(it) => it.favId}
|
||
contentContainerStyle={styles.favList}
|
||
showsVerticalScrollIndicator={false}
|
||
renderItem={({ item }) => (
|
||
<Animated.View
|
||
entering={FadeIn.duration(300)}
|
||
exiting={FadeOut.duration(200)}
|
||
layout={Layout.duration(300).easing(Easing.out(Easing.quad))}
|
||
style={styles.favCard}
|
||
>
|
||
<View style={styles.favLeft}>
|
||
<Text style={styles.favDate}>{item.date}</Text>
|
||
</View>
|
||
<View style={styles.favRight}>
|
||
<View style={[
|
||
styles.favThumb,
|
||
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
|
||
]}>
|
||
{item.themeMode === 'scenery' ? (
|
||
<View style={StyleSheet.absoluteFill}>
|
||
<Image
|
||
source={NATURE_IMAGES[parseInt(item.background)]}
|
||
style={{
|
||
width: width * 0.6,
|
||
height: 800, // 假设原图较高,设置一个较大的高度
|
||
position: 'absolute',
|
||
bottom: 0, // 关键:将图片底部对齐容器底部
|
||
}}
|
||
resizeMode="cover"
|
||
/>
|
||
</View>
|
||
) : null}
|
||
<Text style={[
|
||
styles.favThumbText,
|
||
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
|
||
]} numberOfLines={4}>
|
||
{item.text}
|
||
</Text>
|
||
<Pressable
|
||
onPress={() => handleRemove(item.favId)}
|
||
style={styles.favRemoveBtn}
|
||
hitSlop={10}
|
||
>
|
||
<MyLikeIcon width={37 * 0.7} height={33 * 0.7} />
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
</Animated.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);
|
||
const [hasSystemPermission, setHasSystemPermission] = useState<boolean | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
if (!visible) return;
|
||
setLoading(true);
|
||
(async () => {
|
||
// 1. 获取本地存储设置
|
||
const s = await getDailyReminderSettings();
|
||
|
||
// 2. 获取系统通知权限(用于 UI 展示/引导)
|
||
const settings = await Notifications.getPermissionsAsync();
|
||
const granted = settings.status === 'granted';
|
||
|
||
if (cancelled) return;
|
||
setTimesPerDay(s.timesPerDay);
|
||
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
|
||
setPushEnabled(Boolean(s.pushEnabled) && granted);
|
||
setHasSystemPermission(granted);
|
||
setLoading(false);
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [visible]);
|
||
|
||
const handleTogglePush = async (value: boolean) => {
|
||
if (value) {
|
||
// 获取当前权限状态
|
||
const settings = await Notifications.getPermissionsAsync();
|
||
|
||
// 如果已经拒绝,弹窗提示
|
||
if (settings.status === 'denied') {
|
||
Alert.alert(
|
||
t('common.notice'),
|
||
t('permissions.notificationsDenied')
|
||
);
|
||
setPushEnabled(false);
|
||
return;
|
||
}
|
||
|
||
// 尝试申请权限
|
||
const { status } = await Notifications.requestPermissionsAsync();
|
||
|
||
// 调试:打印状态
|
||
console.log('Push Permission Status:', status);
|
||
|
||
if (status === 'granted') {
|
||
setPushEnabled(true);
|
||
setHasSystemPermission(true);
|
||
|
||
// 获取 token 并上报后端(幂等)
|
||
try {
|
||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||
await registerPushToken({ pushToken: expoPushToken });
|
||
// 偏好同步失败不应被用户感知为“开启失败”
|
||
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
||
try {
|
||
await setPushPreferences({ enabled: true, timesPerDay });
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg);
|
||
}
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
Alert.alert(t('common.notice'), msg);
|
||
}
|
||
} else {
|
||
setPushEnabled(false);
|
||
setHasSystemPermission(false);
|
||
Alert.alert(t('common.notice'), t('dailyReminder.permissionDenied'));
|
||
}
|
||
} else {
|
||
setPushEnabled(false);
|
||
// 关闭时尝试同步到后端(不阻塞)
|
||
try {
|
||
await setPushPreferences({ enabled: false, timesPerDay: 0 });
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
};
|
||
|
||
function clamp(next: number) {
|
||
// 需求:0~5(0 表示关闭)
|
||
return Math.min(5, Math.max(0, next));
|
||
}
|
||
|
||
async function onOk() {
|
||
if (loading) return;
|
||
setLoading(true);
|
||
const nextTimes = Math.min(5, Math.max(0, Math.round(timesPerDay)));
|
||
const nextEnabled = Boolean(pushEnabled) && nextTimes > 0;
|
||
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
|
||
await setDailyReminderSettings(next);
|
||
|
||
// 同步后端偏好(幂等;失败不阻塞)
|
||
try {
|
||
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
console.warn('[PushPreferences] 同步失败', msg);
|
||
}
|
||
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>
|
||
<View style={styles.rowRight}>
|
||
<Switch
|
||
value={pushEnabled}
|
||
onValueChange={handleTogglePush}
|
||
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
|
||
ios_backgroundColor="#D1D1D6"
|
||
/>
|
||
</View>
|
||
</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({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||
const { t, i18n } = useTranslation();
|
||
const currentLang = i18n.language;
|
||
|
||
// 根据语言选择图片
|
||
const widget1 = currentLang === 'en'
|
||
? require('@/assets/images/home/Profile/widget/Widget1_en.png')
|
||
: require('@/assets/images/home/Profile/widget/Widget1_tw.png');
|
||
|
||
const widget2 = currentLang === 'en'
|
||
? require('@/assets/images/home/Profile/widget/Widget2_en.png')
|
||
: require('@/assets/images/home/Profile/widget/Widget2_tw.png');
|
||
|
||
return (
|
||
<View style={styles.widgetContent}>
|
||
<Pressable style={styles.questionBtn} onPress={onOpenHowTo}>
|
||
<QuestionIcon width={19} height={19} />
|
||
</Pressable>
|
||
|
||
<View style={styles.widgetScroll}>
|
||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||
</Pressable>
|
||
|
||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||
<Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function WidgetHowToPage() {
|
||
const { t, i18n } = useTranslation();
|
||
const currentLang = i18n.language;
|
||
const flatListRef = useRef<FlatList>(null);
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const [isManual, setIsManual] = useState(false);
|
||
// React Native 环境下 setInterval 返回值类型与 Node 不同,这里用 ReturnType 兼容
|
||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const images = currentLang === 'en' ? [
|
||
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
||
{ id: '2', src: require('@/assets/images/home/Profile/widget/Widget_description2_en.png'), desc: t('widget.howToDesc2') },
|
||
] : [
|
||
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_tw.png'), desc: t('widget.howToDesc1') },
|
||
{ id: '2', src: require('@/assets/images/home/Profile/widget/Widget_description2_tw.png'), desc: t('widget.howToDesc2') },
|
||
];
|
||
|
||
const startAutoPlay = useCallback(() => {
|
||
if (timerRef.current) clearInterval(timerRef.current);
|
||
timerRef.current = setInterval(() => {
|
||
if (!isManual) {
|
||
const nextIndex = (activeIndex + 1) % images.length;
|
||
flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true });
|
||
setActiveIndex(nextIndex);
|
||
}
|
||
}, 3000);
|
||
}, [activeIndex, isManual, images.length]);
|
||
|
||
useEffect(() => {
|
||
startAutoPlay();
|
||
return () => {
|
||
if (timerRef.current) clearInterval(timerRef.current);
|
||
};
|
||
}, [startAutoPlay]);
|
||
|
||
const onScroll = (event: any) => {
|
||
const x = event.nativeEvent.contentOffset.x;
|
||
const index = Math.round(x / (width - 32));
|
||
if (index !== activeIndex) {
|
||
setActiveIndex(index);
|
||
}
|
||
};
|
||
|
||
const onScrollBeginDrag = () => {
|
||
setIsManual(true);
|
||
if (timerRef.current) clearInterval(timerRef.current);
|
||
};
|
||
|
||
return (
|
||
<View style={styles.howToPage}>
|
||
<FlatList
|
||
ref={flatListRef}
|
||
data={images}
|
||
keyExtractor={(item) => item.id}
|
||
horizontal
|
||
pagingEnabled
|
||
showsHorizontalScrollIndicator={false}
|
||
onScroll={onScroll}
|
||
onScrollBeginDrag={onScrollBeginDrag}
|
||
scrollEventThrottle={16}
|
||
renderItem={({ item }) => (
|
||
<View style={styles.howToSlide}>
|
||
<Image source={item.src} style={styles.howToImg} resizeMode="contain" />
|
||
<Text style={styles.howToDesc}>{item.desc}</Text>
|
||
</View>
|
||
)}
|
||
/>
|
||
|
||
<View style={styles.pagination}>
|
||
{images.map((_, i) => (
|
||
<View
|
||
key={i}
|
||
style={[
|
||
styles.dot,
|
||
i === activeIndex ? styles.dotActive : styles.dotInactive
|
||
]}
|
||
/>
|
||
))}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function LanguagePage() {
|
||
const { t, i18n } = useTranslation();
|
||
const currentLang = i18n.language;
|
||
|
||
const languages = [
|
||
{ id: 'zh-TW', label: t('language.zhTW') },
|
||
{ id: 'en', label: t('language.en') },
|
||
];
|
||
|
||
return (
|
||
<View style={styles.langPage}>
|
||
<View style={styles.langList}>
|
||
{languages.map((lang, index) => (
|
||
<Pressable
|
||
key={lang.id}
|
||
style={[
|
||
styles.langItem,
|
||
index < languages.length - 1 && styles.langItemBorder
|
||
]}
|
||
onPress={() => changeLanguage(lang.id as any)}
|
||
>
|
||
<Text style={styles.langText}>{lang.label}</Text>
|
||
{currentLang === lang.id && (
|
||
<SelectedIcon width={20} height={20} />
|
||
)}
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
</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: 20,
|
||
padding: 14,
|
||
backgroundColor: 'rgba(244,214,194,0.65)',
|
||
height: 142,
|
||
justifyContent: 'space-between',
|
||
},
|
||
quickTitle: {
|
||
color: '#482A0D',
|
||
fontSize: 15,
|
||
fontWeight: '500',
|
||
},
|
||
quickIconWrap: {
|
||
alignItems: 'flex-end',
|
||
justifyContent: 'flex-end',
|
||
flex: 1
|
||
},
|
||
list: {
|
||
borderRadius: 20,
|
||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||
overflow: 'hidden',
|
||
marginBottom: 8,
|
||
},
|
||
item: {
|
||
height: 52,
|
||
paddingHorizontal: 18,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||
borderBottomColor: 'rgba(94,42,40,0.10)',
|
||
},
|
||
itemLeft: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
},
|
||
itemIcon: {
|
||
width: 24,
|
||
height: 24,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
itemText: {
|
||
color: '#522B09',
|
||
fontSize: 15,
|
||
fontWeight: '500',
|
||
},
|
||
chevron: {
|
||
color: '#C7B4A1',
|
||
fontSize: 20,
|
||
marginTop: -2,
|
||
},
|
||
|
||
favContainer: {
|
||
flex: 1,
|
||
minHeight: 500, // 确保容器有足够高度
|
||
},
|
||
favEmpty: {
|
||
color: 'rgba(94,42,40,0.55)',
|
||
fontSize: 15,
|
||
textAlign: 'center',
|
||
paddingVertical: 100,
|
||
},
|
||
favList: {
|
||
paddingHorizontal: 20,
|
||
paddingBottom: 80,
|
||
},
|
||
favCard: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: 24,
|
||
},
|
||
favLeft: {
|
||
width: 90,
|
||
},
|
||
favDate: {
|
||
fontSize: 14,
|
||
color: '#772F00',
|
||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||
fontWeight: '600',
|
||
},
|
||
favRight: {
|
||
flex: 1,
|
||
},
|
||
favThumb: {
|
||
backgroundColor: '#FFF4EA',
|
||
borderRadius: 16,
|
||
padding: 20,
|
||
width: width * 0.6,
|
||
height: 161,
|
||
justifyContent: 'center',
|
||
position: 'relative',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(119, 47, 0, 0.05)',
|
||
overflow: 'hidden',
|
||
},
|
||
favThumbText: {
|
||
fontSize: 15,
|
||
lineHeight: 22,
|
||
color: '#5E2A28',
|
||
fontWeight: '500',
|
||
textAlign: 'center',
|
||
},
|
||
favRemoveBtn: {
|
||
position: 'absolute',
|
||
top: 15,
|
||
right: 15,
|
||
width: 37,
|
||
height: 33,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
|
||
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: 20,
|
||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||
paddingHorizontal: 14,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 18,
|
||
width: width - 40, // 屏幕宽度减去左右各 20pt
|
||
alignSelf: 'center',
|
||
},
|
||
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||
rowRight: {
|
||
height: '100%',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
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,
|
||
marginTop: 40, // 增加顶部间距以撑开高度
|
||
},
|
||
okBtn: {
|
||
height: 52,
|
||
borderRadius: 26,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
okBtnDisabled: { opacity: 0.7 },
|
||
okText: {
|
||
color: '#fff',
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
},
|
||
|
||
widgetContent: {
|
||
flex: 1,
|
||
paddingTop: 10,
|
||
},
|
||
questionBtn: {
|
||
position: 'absolute',
|
||
right: 20,
|
||
top: -34, // 放在标题栏右侧
|
||
zIndex: 10,
|
||
},
|
||
widgetScroll: {
|
||
alignItems: 'center',
|
||
gap: 30,
|
||
paddingBottom: 40,
|
||
},
|
||
widgetItem: {
|
||
alignItems: 'center',
|
||
width: '100%',
|
||
},
|
||
widgetImg1: {
|
||
width: width * 0.9,
|
||
height: (width * 0.9) * (156 / 311),
|
||
},
|
||
widgetImg2: {
|
||
width: width * 0.9,
|
||
height: (width * 0.9) * (175 / 311),
|
||
},
|
||
widgetLabel: {
|
||
marginTop: 12,
|
||
fontSize: 15,
|
||
color: 'rgba(94, 42, 40, 0.45)',
|
||
fontWeight: '500',
|
||
},
|
||
howToPage: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
paddingTop: 20,
|
||
},
|
||
howToSlide: {
|
||
width: width - 32, // 减去 SheetModal 的 paddingHorizontal: 16 * 2
|
||
alignItems: 'center',
|
||
},
|
||
howToImg: {
|
||
width: width * 0.9,
|
||
height: (width * 0.9) * (234 / 326),
|
||
marginBottom: 40,
|
||
},
|
||
howToDesc: {
|
||
fontSize: 15,
|
||
lineHeight: 25,
|
||
color: '#522B09',
|
||
textAlign: 'center',
|
||
fontWeight: '500',
|
||
paddingHorizontal: 20,
|
||
},
|
||
pagination: {
|
||
flexDirection: 'row',
|
||
position: 'absolute',
|
||
top: (width * 0.9) * (234 / 326) + 35, // 根据新的图片高度动态计算
|
||
gap: 8,
|
||
},
|
||
dot: {
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: 4,
|
||
},
|
||
dotActive: {
|
||
backgroundColor: '#EEB054',
|
||
},
|
||
dotInactive: {
|
||
backgroundColor: 'rgba(238, 176, 84, 0.3)',
|
||
},
|
||
langPage: {
|
||
paddingHorizontal: 20,
|
||
paddingTop: 10,
|
||
},
|
||
langList: {
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 20,
|
||
overflow: 'hidden',
|
||
width: width - 40, // 屏幕宽度减去左右各 20pt
|
||
alignSelf: 'center',
|
||
},
|
||
langItem: {
|
||
height: 62,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 24,
|
||
},
|
||
langItemBorder: {
|
||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||
borderBottomColor: 'rgba(94,42,40,0.1)',
|
||
},
|
||
langText: {
|
||
fontSize: 15,
|
||
color: '#522B09',
|
||
fontWeight: '500',
|
||
textTransform: 'capitalize',
|
||
},
|
||
});
|
||
|