Files
mindfulness/client/components/home/ProfileModal.tsx
2026-03-03 19:55:53 +08:00

1125 lines
32 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, useRef, useCallback } from 'react';
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } 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 { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
import { isIPadLike } from '@/src/utils/device';
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 { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? width - 40 : width - 40;
const thumbWidth = isTablet ? Math.min(420, contentWidth - 120) : Math.min(width * 0.6, 300);
const widgetImageWidth = isTablet ? Math.min(520, contentWidth - 24) : width * 0.9;
const howToSlideWidth = isTablet ? Math.min(640, contentWidth) : width - 32;
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}
contentWidth={contentWidth}
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} thumbWidth={thumbWidth} contentWidth={contentWidth} />
) : page === 'dailyReminder' ? (
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} contentWidth={contentWidth} />
) : page === 'language' ? (
<LanguagePage contentWidth={contentWidth} />
) : page === 'widgetHowTo' ? (
<WidgetHowToPage howToSlideWidth={howToSlideWidth} widgetImageWidth={widgetImageWidth} />
) : (
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} widgetImageWidth={widgetImageWidth} />
)}
</Animated.View>
</View>
</SheetModal>
);
}
function toastTodo(t: (key: string) => string) {
Alert.alert(t('profile.todoTitle'), t('profile.todoDesc'));
}
function RootPage({
name,
contentWidth,
onOpenFavorites,
onOpenWidget,
onOpenDailyReminder,
onOpenLanguage,
onOpenPrivacy,
onOpenTerms,
}: {
name?: string;
contentWidth: number;
onOpenFavorites: () => void;
onOpenWidget: () => void;
onOpenDailyReminder: () => void;
onOpenLanguage: () => void;
onOpenPrivacy: () => void;
onOpenTerms: () => void;
}) {
const { t } = useTranslation();
return (
<View style={[styles.sectionWrap, { width: contentWidth }]}>
<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>
<Text style={styles.versionText}>V1.0.0</Text>
</View>
);
}
function FavoritesPage({
visible,
page,
thumbWidth,
contentWidth,
}: {
visible: boolean;
page: Page;
thumbWidth: number;
contentWidth: number;
}) {
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, { width: contentWidth, alignSelf: 'center' }]}>
{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,
{ width: thumbWidth },
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
]}>
{item.themeMode === 'scenery' ? (
<View style={StyleSheet.absoluteFill}>
<Image
source={NATURE_IMAGES[parseInt(item.background)]}
style={{
width: thumbWidth,
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,
contentWidth,
}: {
visible: boolean;
onDone: () => void;
contentWidth: number;
}) {
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' || (status as any) === 'provisional') {
setPushEnabled(true);
setHasSystemPermission(true);
// 获取 token 并上报后端(幂等)
try {
await ensurePushTokenRegisteredIfPermitted();
// 偏好同步失败不应被用户感知为“开启失败”
// (常见现象:后端已接收 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) {
// 需求050 表示关闭)
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);
// 若系统权限已授予且用户意愿为开启:兜底同步一次 token避免“没点开关/没触发 toggle 导致后端无 token”
if (nextEnabled) {
ensurePushTokenRegisteredIfPermitted().catch(() => {
// ignore不阻塞保存
});
}
// 同步后端偏好(幂等;失败不阻塞)
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, { width: contentWidth }]}>
<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, widgetImageWidth }: { onOpenHowTo: () => void; widgetImageWidth: number }) {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
const showLockScreenWidget = false;
// 根据语言选择图片
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}>
{showLockScreenWidget ? (
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget1} style={[styles.widgetImg1, { width: widgetImageWidth, height: widgetImageWidth * (156 / 311) }]} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
</Pressable>
) : null}
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget2} style={[styles.widgetImg2, { width: widgetImageWidth, height: widgetImageWidth * (175 / 311) }]} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text>
</Pressable>
</View>
</View>
);
}
function WidgetHowToPage({ howToSlideWidth, widgetImageWidth }: { howToSlideWidth: number; widgetImageWidth: number }) {
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 / howToSlideWidth);
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, { width: howToSlideWidth }]}>
<Image source={item.src} style={[styles.howToImg, { width: widgetImageWidth, height: widgetImageWidth * (234 / 326) }]} resizeMode="contain" />
<Text style={styles.howToDesc}>{item.desc}</Text>
</View>
)}
/>
<View style={[styles.pagination, { top: widgetImageWidth * (234 / 326) + 35 }]}>
{images.map((_, i) => (
<View
key={i}
style={[
styles.dot,
i === activeIndex ? styles.dotActive : styles.dotInactive
]}
/>
))}
</View>
</View>
);
}
function LanguagePage({ contentWidth }: { contentWidth: number }) {
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, { width: contentWidth, alignSelf: 'center' }]}>
<View style={[styles.langList, { width: contentWidth }]}>
{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: {
// 给页面切换动画一个稳定的容器,避免布局抖动
},
sectionWrap: {
alignSelf: 'center',
},
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,
},
versionText: {
marginTop: 12,
textAlign: 'center',
color: 'rgba(94,42,40,0.45)',
fontSize: 12,
fontWeight: '500',
},
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: 8,
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,
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,
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: 320,
height: 160,
},
widgetImg2: {
width: 320,
height: 176,
},
widgetLabel: {
marginTop: 12,
fontSize: 15,
color: 'rgba(94, 42, 40, 0.45)',
fontWeight: '500',
},
howToPage: {
flex: 1,
alignItems: 'center',
paddingTop: 20,
},
howToSlide: {
width: 320,
alignItems: 'center',
},
howToImg: {
width: 320,
height: 230,
marginBottom: 40,
},
howToDesc: {
fontSize: 15,
lineHeight: 25,
color: '#522B09',
textAlign: 'center',
fontWeight: '500',
paddingHorizontal: 20,
},
pagination: {
flexDirection: 'row',
position: 'absolute',
top: 265,
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',
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',
},
});