feat: 完善个人中心与小组件功能,优化 Onboarding 交互与手势体验
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
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';
|
||||
@@ -11,6 +11,7 @@ import Animated, {
|
||||
SlideInRight,
|
||||
SlideOutLeft,
|
||||
SlideOutRight,
|
||||
Layout,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import SheetModal from '@/components/ui/SheetModal';
|
||||
@@ -20,16 +21,25 @@ import {
|
||||
getDailyReminderSettings,
|
||||
getFavorites,
|
||||
setDailyReminderSettings,
|
||||
removeFavorite,
|
||||
getUserProfile,
|
||||
type DailyReminderSettings,
|
||||
type FavoriteItem,
|
||||
} 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';
|
||||
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';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
@@ -37,24 +47,38 @@ type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget';
|
||||
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
|
||||
type NavDirection = 'forward' | 'back';
|
||||
|
||||
export default function ProfileModal({ visible, name, onClose }: Props) {
|
||||
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 isRoot = page === 'root';
|
||||
|
||||
// 关闭弹窗时重置为首页,避免下次打开停留在二级页
|
||||
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
if (visible) {
|
||||
getUserProfile().then(profile => {
|
||||
if (profile.name) {
|
||||
setCurrentName(profile.name);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setNavDirection('back');
|
||||
setPage('root');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 同步外部 propName 的变化
|
||||
useEffect(() => {
|
||||
if (propName) {
|
||||
setCurrentName(propName);
|
||||
}
|
||||
}, [propName]);
|
||||
|
||||
function go(next: Page, direction: NavDirection) {
|
||||
setNavDirection(direction);
|
||||
setPage(next);
|
||||
@@ -79,6 +103,8 @@ export default function ProfileModal({ visible, name, onClose }: Props) {
|
||||
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]);
|
||||
|
||||
@@ -107,42 +133,46 @@ export default function ProfileModal({ visible, name, onClose }: Props) {
|
||||
}, [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}
|
||||
<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}
|
||||
>
|
||||
<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}>
|
||||
<Animated.View
|
||||
entering={transition.fadeIn}
|
||||
exiting={transition.fadeOut}
|
||||
style={!isRoot ? { flex: 1 } : undefined}
|
||||
>
|
||||
{page === 'root' ? (
|
||||
<RootPage
|
||||
name={name}
|
||||
name={currentName}
|
||||
onOpenFavorites={() => go('favorites', 'forward')}
|
||||
onOpenWidget={() => go('widget', 'forward')}
|
||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||
onOpenLanguage={() => go('language', 'forward')}
|
||||
/>
|
||||
) : page === 'favorites' ? (
|
||||
<FavoritesPage visible={visible} />
|
||||
<FavoritesPage visible={visible} page={page} />
|
||||
) : page === 'dailyReminder' ? (
|
||||
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
|
||||
) : page === 'language' ? (
|
||||
<LanguagePage />
|
||||
) : page === 'widgetHowTo' ? (
|
||||
<WidgetHowToPage />
|
||||
) : (
|
||||
<WidgetPage />
|
||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</SheetModal>
|
||||
);
|
||||
}
|
||||
@@ -156,17 +186,19 @@ function RootPage({
|
||||
onOpenFavorites,
|
||||
onOpenWidget,
|
||||
onOpenDailyReminder,
|
||||
onOpenLanguage,
|
||||
}: {
|
||||
name?: string;
|
||||
onOpenFavorites: () => void;
|
||||
onOpenWidget: () => void;
|
||||
onOpenDailyReminder: () => void;
|
||||
onOpenLanguage: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<Image source={AvatarIcon} style={styles.avatarImage} resizeMode="contain" />
|
||||
<AvatarIcon width={234} height={183} />
|
||||
<Text style={styles.name}>{name || 'Hali'}</Text>
|
||||
</View>
|
||||
|
||||
@@ -198,48 +230,79 @@ function RootPage({
|
||||
<ListItem
|
||||
icon={<LanguageIcon width={22} height={22} />}
|
||||
title={t('profile.language')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenLanguage}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FavoritesPage({ visible }: { visible: boolean }) {
|
||||
function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
const { t } = useTranslation();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]);
|
||||
|
||||
// 每次进入该页刷新一次,确保展示最新“喜欢”
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await getFavorites();
|
||||
if (!cancelled) setIds(list);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible]);
|
||||
refreshFavorites();
|
||||
}, [visible, page]); // 当页面切换回 favorites 时也刷新一次
|
||||
|
||||
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]);
|
||||
async function refreshFavorites() {
|
||||
const storedFavs = await getFavorites();
|
||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
|
||||
|
||||
const list = storedFavs
|
||||
.map((fav) => ({
|
||||
...fav,
|
||||
text: map.get(fav.id) || ''
|
||||
}))
|
||||
.filter(item => item.text !== '');
|
||||
|
||||
setFavorites(list);
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
// 1. 调用存储层移除收藏
|
||||
await removeFavorite(id);
|
||||
// 2. 更新本地状态
|
||||
setFavorites(prev => prev.filter(item => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.favContainer}>
|
||||
{items.length === 0 ? (
|
||||
{favorites.length === 0 ? (
|
||||
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
data={favorites}
|
||||
keyExtractor={(it) => it.id}
|
||||
contentContainerStyle={styles.favList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.favRow}>
|
||||
<Text style={styles.favText}>{item.text}</Text>
|
||||
</View>
|
||||
<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,
|
||||
{ backgroundColor: item.background } // 动态同步 Home 页的背景
|
||||
]}>
|
||||
<Text style={styles.favThumbText} numberOfLines={4}>{item.text}</Text>
|
||||
<Pressable
|
||||
onPress={() => handleRemove(item.id)}
|
||||
style={styles.favRemoveBtn}
|
||||
hitSlop={10}
|
||||
>
|
||||
<MyLikeIcon width={37 * 0.7} height={33 * 0.7} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -252,16 +315,23 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
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();
|
||||
|
||||
// 【测试模式】:强制模拟无权限状态
|
||||
const granted = false;
|
||||
|
||||
if (cancelled) return;
|
||||
setTimesPerDay(s.timesPerDay);
|
||||
setPushEnabled(s.pushEnabled);
|
||||
setPushEnabled(granted);
|
||||
setHasSystemPermission(granted);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
@@ -269,6 +339,40 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const handleTogglePush = async (value: boolean) => {
|
||||
if (value) {
|
||||
// 获取当前权限状态
|
||||
const settings = await Notifications.getPermissionsAsync();
|
||||
|
||||
// 如果已经拒绝,弹窗提示
|
||||
if (settings.status === 'denied') {
|
||||
Alert.alert(
|
||||
t('common.notice'),
|
||||
"系统权限已被拒绝,请前往手机设置开启通知。"
|
||||
);
|
||||
setPushEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试申请权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
|
||||
// 调试:打印状态
|
||||
console.log('Push Permission Status:', status);
|
||||
|
||||
if (status === 'granted') {
|
||||
setPushEnabled(true);
|
||||
setHasSystemPermission(true);
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
setHasSystemPermission(false);
|
||||
Alert.alert(t('common.notice'), t('dailyReminder.permissionDenied'));
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
function clamp(next: number) {
|
||||
return Math.min(10, Math.max(1, next));
|
||||
}
|
||||
@@ -311,15 +415,24 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.remindRow}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIcon}>
|
||||
<RemindIcon width={18} height={18} />
|
||||
{!hasSystemPermission && (
|
||||
<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>
|
||||
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||
</View>
|
||||
<Switch value={pushEnabled} onValueChange={setPushEnabled} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
|
||||
<LinearGradient
|
||||
@@ -335,34 +448,150 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetPage() {
|
||||
const { t } = useTranslation();
|
||||
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}>
|
||||
<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>
|
||||
<Pressable style={styles.questionBtn} onPress={onOpenHowTo}>
|
||||
<QuestionIcon width={19} height={19} />
|
||||
</Pressable>
|
||||
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
<Text style={styles.widgetDesc}>{t('settings.widgetDesc')}</Text>
|
||||
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);
|
||||
const timerRef = useRef<NodeJS.Timeout | 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 { i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
|
||||
const languages = [
|
||||
{ id: 'zh-TW', label: '繁体' },
|
||||
{ id: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -442,26 +671,31 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
quickCard: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
borderRadius: 20,
|
||||
padding: 14,
|
||||
backgroundColor: 'rgba(244,214,194,0.65)',
|
||||
height: 142,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
quickTitle: {
|
||||
color: '#5E2A28',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginBottom: 10,
|
||||
color: '#482A0D',
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
quickIconWrap: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-end',
|
||||
flex: 1
|
||||
},
|
||||
quickIconWrap: { alignItems: 'center', justifyContent: 'center', flex: 1 },
|
||||
list: {
|
||||
borderRadius: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
},
|
||||
item: {
|
||||
height: 52,
|
||||
paddingHorizontal: 14,
|
||||
paddingHorizontal: 18,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
@@ -471,55 +705,82 @@ const styles = StyleSheet.create({
|
||||
itemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
gap: 12,
|
||||
},
|
||||
itemIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(244,214,194,0.55)',
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
itemText: {
|
||||
color: '#5E2A28',
|
||||
color: '#522B09',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
fontWeight: '500',
|
||||
},
|
||||
chevron: {
|
||||
color: 'rgba(94,42,40,0.45)',
|
||||
color: '#C7B4A1',
|
||||
fontSize: 20,
|
||||
marginTop: -2,
|
||||
},
|
||||
|
||||
favContainer: {
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
flex: 1,
|
||||
minHeight: 500, // 确保容器有足够高度
|
||||
},
|
||||
favEmpty: {
|
||||
color: 'rgba(94,42,40,0.55)',
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
paddingVertical: 26,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 100,
|
||||
},
|
||||
favList: {
|
||||
paddingBottom: 10,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
favRow: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(94,42,40,0.10)',
|
||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||
favCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
favText: {
|
||||
color: '#5E2A28',
|
||||
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)',
|
||||
},
|
||||
favThumbText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontWeight: '600',
|
||||
color: '#5E2A28',
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
favRemoveBtn: {
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
width: 37,
|
||||
height: 33,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
counter: {
|
||||
@@ -563,15 +824,22 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
remindRow: {
|
||||
height: 54,
|
||||
borderRadius: 16,
|
||||
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,
|
||||
@@ -587,6 +855,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
okPressable: {
|
||||
marginBottom: 6,
|
||||
marginTop: 40, // 增加顶部间距以撑开高度
|
||||
},
|
||||
okBtn: {
|
||||
height: 52,
|
||||
@@ -602,61 +871,104 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
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)',
|
||||
paddingTop: 10,
|
||||
},
|
||||
widgetPreviewInner: {
|
||||
height: 120,
|
||||
padding: 12,
|
||||
justifyContent: 'center',
|
||||
questionBtn: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: -34, // 放在标题栏右侧
|
||||
zIndex: 10,
|
||||
},
|
||||
widgetPreviewLock: {
|
||||
backgroundColor: 'rgba(244,214,194,0.65)',
|
||||
widgetScroll: {
|
||||
alignItems: 'center',
|
||||
gap: 30,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
widgetPreviewHome: {
|
||||
backgroundColor: 'rgba(243,208,225,0.55)',
|
||||
widgetItem: {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
widgetPreviewDate: {
|
||||
color: 'rgba(94,42,40,0.70)',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginBottom: 6,
|
||||
widgetImg1: {
|
||||
width: width * 0.9,
|
||||
height: (width * 0.9) * (156 / 311),
|
||||
},
|
||||
widgetPreviewTime: {
|
||||
color: '#ffffff',
|
||||
fontSize: 44,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
widgetImg2: {
|
||||
width: width * 0.9,
|
||||
height: (width * 0.9) * (175 / 311),
|
||||
},
|
||||
widgetPreviewQuote: {
|
||||
color: '#5E2A28',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: '700',
|
||||
widgetLabel: {
|
||||
marginTop: 12,
|
||||
fontSize: 15,
|
||||
color: 'rgba(94, 42, 40, 0.45)',
|
||||
fontWeight: '500',
|
||||
},
|
||||
widgetCardLabel: {
|
||||
paddingVertical: 10,
|
||||
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',
|
||||
color: '#5E2A28',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
widgetDesc: {
|
||||
color: 'rgba(94,42,40,0.75)',
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose}>
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
|
||||
<View style={styles.row}>
|
||||
<ThemeCard
|
||||
title={t('theme.scenery')}
|
||||
@@ -24,7 +24,7 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
||||
onPress={() => onSelect('scenery')}
|
||||
>
|
||||
<Image
|
||||
source={require('../../assets/images/index/index_flowers.png')}
|
||||
source={require('../../assets/images/theme/theme_landscape.png')}
|
||||
resizeMode="cover"
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
@@ -35,7 +35,11 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
||||
selected={mode === 'color'}
|
||||
onPress={() => onSelect('color')}
|
||||
>
|
||||
<View style={styles.colorPreview} />
|
||||
<Image
|
||||
source={require('../../assets/images/theme/theme_color.png')}
|
||||
resizeMode="cover"
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
</ThemeCard>
|
||||
</View>
|
||||
</SheetModal>
|
||||
@@ -56,11 +60,20 @@ function ThemeCard({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.card, selected ? styles.cardSelected : styles.cardUnselected]}
|
||||
style={styles.cardContainer}
|
||||
hitSlop={6}
|
||||
>
|
||||
<View style={styles.preview}>{children}</View>
|
||||
<Text style={styles.cardTitle}>{title}</Text>
|
||||
<View style={[styles.previewWrapper, selected && styles.selectedWrapper]}>
|
||||
<View style={styles.previewInner}>
|
||||
{children}
|
||||
{/* 文案展示在图片中心 */}
|
||||
<View style={styles.textOverlay}>
|
||||
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -68,43 +81,57 @@ function ThemeCard({
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 14,
|
||||
paddingBottom: 18,
|
||||
gap: 30,
|
||||
paddingHorizontal: 10,
|
||||
paddingBottom: 50,
|
||||
paddingTop: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
card: {
|
||||
flex: 1,
|
||||
borderRadius: 18,
|
||||
padding: 10,
|
||||
backgroundColor: 'rgba(255,255,255,0.55)',
|
||||
cardContainer: {
|
||||
alignItems: 'center',
|
||||
width: 143,
|
||||
},
|
||||
cardSelected: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#F99CC0',
|
||||
previewWrapper: {
|
||||
width: 138,
|
||||
height: 203,
|
||||
borderRadius: 26,
|
||||
padding: 6.5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardUnselected: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(94,42,40,0.18)',
|
||||
selectedWrapper: {
|
||||
borderWidth: 4,
|
||||
borderColor: '#E7837A',
|
||||
borderRadius: 26,
|
||||
},
|
||||
preview: {
|
||||
height: 96,
|
||||
borderRadius: 14,
|
||||
previewInner: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 21,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#fff',
|
||||
marginBottom: 10,
|
||||
backgroundColor: '#F5F5F5',
|
||||
position: 'relative',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
colorPreview: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F3D0E1',
|
||||
textOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)', // 轻微遮罩增加文字可读性
|
||||
},
|
||||
cardTitle: {
|
||||
color: '#5E2A28',
|
||||
fontSize: 14,
|
||||
overlayTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
selectedOverlayTitle: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +1,82 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform } from 'react-native';
|
||||
import { SerifText } from './SerifText';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface NameInputStepProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function NameInputStep({ value, onChangeText, onSubmitEditing }: NameInputStepProps) {
|
||||
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||
const hasInput = value.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(blinkAnim, { toValue: 0, duration: 500, useNativeDriver: true }),
|
||||
Animated.timing(blinkAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
|
||||
])
|
||||
);
|
||||
if (isFocused) {
|
||||
animation.start();
|
||||
} else {
|
||||
animation.stop();
|
||||
blinkAnim.setValue(0);
|
||||
}
|
||||
return () => animation.stop();
|
||||
}, [blinkAnim, isFocused]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SerifText style={styles.title}>我可以怎么称呼你?</SerifText>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="Hao"
|
||||
placeholderTextColor={OnboardingColors.textSecondary}
|
||||
selectionColor={OnboardingColors.cursor}
|
||||
autoFocus
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="done"
|
||||
textAlign="center"
|
||||
/>
|
||||
<View style={styles.inputCard}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{/* 显示层:文案 + 跟随的光标 */}
|
||||
<View style={styles.displayLayer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.displayText,
|
||||
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
||||
]}
|
||||
>
|
||||
{hasInput ? value : (isFocused ? "" : "Mama")}
|
||||
</Text>
|
||||
{isFocused && (
|
||||
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
||||
<EnterLightIcon width={3} height={27} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 交互层:隐藏的输入框 */}
|
||||
<TextInput
|
||||
style={styles.hiddenInput}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
caretHidden={true}
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
disabled={!hasInput}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -34,31 +84,53 @@ export function NameInputStep({ value, onChangeText, onSubmitEditing }: NameInpu
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
paddingTop: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
marginBottom: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
width: '100%',
|
||||
inputCard: {
|
||||
width: 335,
|
||||
height: 75,
|
||||
backgroundColor: OnboardingColors.cardBackground,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
input: {
|
||||
fontSize: 24,
|
||||
fontFamily: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }),
|
||||
inputWrapper: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
displayLayer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
displayText: {
|
||||
fontSize: 22,
|
||||
fontFamily: Platform.select({ ios: 'STIX Two Text', android: 'serif', default: 'serif' }),
|
||||
fontWeight: '600',
|
||||
color: OnboardingColors.textPrimary,
|
||||
textAlign: 'center',
|
||||
padding: 0, // remove default padding
|
||||
},
|
||||
hiddenInput: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
color: 'transparent', // 文字透明,只负责输入逻辑
|
||||
fontSize: 22,
|
||||
textAlign: 'center',
|
||||
},
|
||||
cursorWrapper: {
|
||||
// 默认居中显示时,光标在左侧
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,45 +1,61 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar } from 'react-native';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import { SerifText } from './SerifText';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
interface OnboardingLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSkip?: () => void;
|
||||
onNext?: () => void;
|
||||
nextEnabled?: boolean;
|
||||
showNextButton?: boolean;
|
||||
title?: string;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
onSkip: () => void;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
export function OnboardingLayout({
|
||||
children,
|
||||
title,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
onSkip,
|
||||
onNext,
|
||||
nextEnabled = true,
|
||||
showNextButton = true
|
||||
onBack,
|
||||
showBackButton = false
|
||||
}: OnboardingLayoutProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
{/* Header: Back & Skip */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.spacer} />
|
||||
{onSkip && (
|
||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||
<SerifText style={styles.skipText}>Skip {'->'}</SerifText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{children}
|
||||
<View style={styles.headerLeft}>
|
||||
{showBackButton && onBack && (
|
||||
<TouchableOpacity onPress={onBack} style={styles.iconButton}>
|
||||
<Image
|
||||
source={require('@/assets/images/icon/back_icon.png')}
|
||||
style={styles.backIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||
<Text style={styles.skipText}>skip</Text>
|
||||
<Image
|
||||
source={require('@/assets/images/icon/skip_icon.png')}
|
||||
style={styles.skipIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
{showNextButton && onNext && (
|
||||
<NextButton onPress={onNext} disabled={!nextEnabled} />
|
||||
)}
|
||||
{/* Title & Progress Row */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.questionTitle}>{title}</Text>
|
||||
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{children}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
@@ -58,27 +74,60 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
height: 44,
|
||||
},
|
||||
spacer: {
|
||||
width: 60, // Balance the skip button width approximately
|
||||
headerLeft: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconButton: {
|
||||
padding: 8,
|
||||
},
|
||||
backIcon: {
|
||||
width: 19,
|
||||
height: 19,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
skipButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
},
|
||||
skipText: {
|
||||
fontSize: 16,
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontSize: 13,
|
||||
color: OnboardingColors.textMuted,
|
||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||
marginRight: 4,
|
||||
},
|
||||
skipIcon: {
|
||||
width: 10,
|
||||
height: 4,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
questionTitle: {
|
||||
fontSize: 22,
|
||||
color: OnboardingColors.questionTitle,
|
||||
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||
flex: 1,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 18,
|
||||
color: OnboardingColors.textProgress,
|
||||
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
|
||||
marginLeft: 10,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
paddingBottom: 40,
|
||||
minHeight: 100, // Reserve space for button
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
});
|
||||
|
||||
89
client/components/onboarding/ReminderStep.tsx
Normal file
89
client/components/onboarding/ReminderStep.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface ReminderStepProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
const handleReduce = () => {
|
||||
if (value > 1) onChange(value - 1);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
if (value < 5) onChange(value + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.counterContainer}>
|
||||
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
|
||||
<ReduceIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.numberWrapper}>
|
||||
<Text style={styles.numberText}>{value}</Text>
|
||||
<Text style={styles.unitText}>次</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||
<AddIcon width={47} height={47} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||
<BtnClicked width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingTop: 20,
|
||||
},
|
||||
counterContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: 40,
|
||||
},
|
||||
numberWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginHorizontal: 40,
|
||||
},
|
||||
numberText: {
|
||||
fontSize: 107,
|
||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||
fontWeight: '600',
|
||||
color: OnboardingColors.textPrimary,
|
||||
lineHeight: 120,
|
||||
},
|
||||
unitText: {
|
||||
fontSize: 17,
|
||||
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
|
||||
fontWeight: '600',
|
||||
color: OnboardingColors.textPrimary,
|
||||
marginBottom: 20,
|
||||
marginLeft: 4,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
});
|
||||
103
client/components/onboarding/SelectionStep.tsx
Normal file
103
client/components/onboarding/SelectionStep.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import { SerifText } from './SerifText';
|
||||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectionStepProps {
|
||||
options: Option[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext }: SelectionStepProps) {
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.id}
|
||||
style={styles.optionCard}
|
||||
onPress={() => onToggle(option.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<SerifText style={styles.optionText}>{option.label}</SerifText>
|
||||
{isSelected && (
|
||||
<View style={styles.iconWrapper}>
|
||||
<SelectedIcon width={20} height={20} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮:距离底部 12% 高度 */}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
disabled={!hasSelection}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 20,
|
||||
},
|
||||
optionsList: {
|
||||
paddingBottom: 150, // 为底部按钮留出空间
|
||||
},
|
||||
optionCard: {
|
||||
width: '100%',
|
||||
height: 75,
|
||||
backgroundColor: OnboardingColors.cardBackground,
|
||||
borderRadius: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 18,
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
},
|
||||
iconWrapper: {
|
||||
marginLeft: 10,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -9,26 +9,35 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const FIXED_TOP_GAP = 100; // 统一距离顶部的高度
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
leftIcon?: ImageSourcePropType; // 新增:支持自定义左侧图标
|
||||
height?: number; // 新增:支持自定义高度
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用底部上拉弹窗(Sheet)
|
||||
* - 内容区背景色固定:#FAF3EC
|
||||
* - 关闭方式:点 X(本期不要求点遮罩关闭)
|
||||
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
|
||||
*/
|
||||
export default function SheetModal({ visible, title, onClose, children }: Props) {
|
||||
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
||||
const dragY = useSharedValue(0); // 拖拽位移
|
||||
|
||||
const sheetHeight = customHeight || (SCREEN_HEIGHT - FIXED_TOP_GAP);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setMounted(true);
|
||||
dragY.value = 0;
|
||||
progress.value = withTiming(1, { duration: 260, easing: Easing.out(Easing.cubic) });
|
||||
return;
|
||||
}
|
||||
@@ -40,46 +49,88 @@ export default function SheetModal({ visible, title, onClose, children }: Props)
|
||||
if (finished) runOnJS(setMounted)(false);
|
||||
}
|
||||
);
|
||||
}, [visible, mounted, progress]);
|
||||
}, [visible, mounted, progress, dragY]);
|
||||
|
||||
// 使用 PanResponder 处理下滑手势
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: () => false, // 暂时屏蔽下拉关闭手势,解决滑动冲突
|
||||
onPanResponderMove: (_, gestureState) => {
|
||||
if (gestureState.dy > 0) {
|
||||
dragY.value = gestureState.dy;
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: (_, gestureState) => {
|
||||
if (gestureState.dy > 80 || gestureState.vy > 0.5) {
|
||||
runOnJS(onClose)();
|
||||
} else {
|
||||
dragY.value = withTiming(0, {
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.back(1))
|
||||
});
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
dragY.value = withTiming(0, { duration: 200 });
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
const overlayStyle = useAnimatedStyle(() => {
|
||||
return { opacity: 0.4 * progress.value };
|
||||
});
|
||||
|
||||
const sheetStyle = useAnimatedStyle(() => {
|
||||
const translateY = (1 - progress.value) * 380;
|
||||
return { transform: [{ translateY }] };
|
||||
const baseTranslateY = (1 - progress.value) * sheetHeight; // 基础位移
|
||||
return {
|
||||
transform: [{ translateY: baseTranslateY + dragY.value }]
|
||||
};
|
||||
});
|
||||
|
||||
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 12), [insets.bottom]);
|
||||
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 80), [insets.bottom]); // 增加底部间距至 80,约占 350 高度的 22%,确保内容不被截断并留出足够呼吸感
|
||||
|
||||
// 注意:Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
|
||||
return (
|
||||
<Modal transparent visible={mounted} animationType="none" onRequestClose={onClose}>
|
||||
<View style={styles.root}>
|
||||
<Animated.View style={[styles.overlay, overlayStyle]} />
|
||||
<Pressable
|
||||
style={StyleSheet.absoluteFill}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Animated.View style={[styles.overlay, overlayStyle]} />
|
||||
</Pressable>
|
||||
|
||||
<Animated.View
|
||||
{...panResponder.panHandlers}
|
||||
style={[
|
||||
styles.sheet,
|
||||
sheetStyle,
|
||||
{
|
||||
height: sheetHeight,
|
||||
paddingBottom: containerPaddingBottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.handleContainer}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title ?? ''}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="关闭"
|
||||
accessibilityLabel={leftIcon ? "返回" : "关闭"}
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
style={styles.close}
|
||||
>
|
||||
<Text style={styles.closeText}>×</Text>
|
||||
{leftIcon ? (
|
||||
<Image source={leftIcon} style={styles.backIcon} />
|
||||
) : (
|
||||
<Text style={styles.closeText}>×</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -103,9 +154,19 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#FAF3EC',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingTop: 14,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
handleContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
backgroundColor: 'rgba(94,42,40,0.15)',
|
||||
},
|
||||
header: {
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
@@ -131,8 +192,13 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 28,
|
||||
fontWeight: '400',
|
||||
},
|
||||
backIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
body: {
|
||||
paddingTop: 10,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user