feature:基本功能

This commit is contained in:
吕新雨
2026-01-29 19:09:36 +08:00
parent c1a433a469
commit 0dd84b1873
47 changed files with 2978 additions and 219 deletions

View File

@@ -0,0 +1,187 @@
import React, { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Switch, Text, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
import RemindIcon from '@/assets/images/home/Profile/remind.svg';
import {
getDailyReminderSettings,
setDailyReminderSettings,
type DailyReminderSettings,
} from '@/src/storage/appStorage';
type Props = {
visible: boolean;
onClose: () => void;
};
export default function DailyReminderModal({ visible, onClose }: Props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [timesPerDay, setTimesPerDay] = useState(3);
const [pushEnabled, setPushEnabled] = useState(false);
useEffect(() => {
let cancelled = false;
if (!visible) return;
setLoading(true);
(async () => {
const s = await getDailyReminderSettings();
if (cancelled) return;
setTimesPerDay(s.timesPerDay);
setPushEnabled(s.pushEnabled);
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [visible]);
function clamp(next: number) {
return Math.min(10, Math.max(1, next));
}
async function onOk() {
if (loading) return;
setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
await setDailyReminderSettings(next);
setLoading(false);
onClose();
}
return (
<SheetModal visible={visible} title={t('dailyReminder.title')} onClose={onClose}>
<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.row}>
<View style={styles.rowLeft}>
<View style={styles.rowIcon}>
<RemindIcon width={18} height={18} />
</View>
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
</View>
<Switch value={pushEnabled} onValueChange={setPushEnabled} />
</View>
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
<LinearGradient
colors={['#F69F7B', '#F99CC0']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.okBtn, loading && styles.okBtnDisabled]}
>
<Text style={styles.okText}>{t('dailyReminder.ok')}</Text>
</LinearGradient>
</Pressable>
</SheetModal>
);
}
const styles = StyleSheet.create({
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,
},
row: {
height: 54,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.75)',
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 18,
},
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
rowIcon: {
width: 28,
height: 28,
borderRadius: 10,
backgroundColor: 'rgba(244,214,194,0.55)',
alignItems: 'center',
justifyContent: 'center',
},
rowText: {
color: '#5E2A28',
fontSize: 15,
fontWeight: '600',
},
okPressable: {
marginBottom: 6,
},
okBtn: {
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
okBtnDisabled: { opacity: 0.7 },
okText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
});

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useMemo, useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
import { MOCK_CONTENT } from '@/src/constants/mockContent';
import { getFavorites } from '@/src/storage/appStorage';
type Props = {
visible: boolean;
onClose: () => void;
};
export default function FavoritesModal({ visible, onClose }: Props) {
const { t } = useTranslation();
const [ids, setIds] = useState<string[]>([]);
// 每次打开时刷新一次,确保展示最新“喜欢”
useEffect(() => {
if (!visible) return;
let cancelled = false;
(async () => {
const list = await getFavorites();
if (!cancelled) setIds(list);
})();
return () => {
cancelled = true;
};
}, [visible]);
const items = useMemo(() => {
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
}, [ids]);
return (
<SheetModal visible={visible} title={t('profile.favorites')} onClose={onClose}>
<View style={styles.container}>
{items.length === 0 ? (
<Text style={styles.empty}>{t('favorites.empty')}</Text>
) : (
<FlatList
data={items}
keyExtractor={(it) => it.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.text}>{item.text}</Text>
</View>
)}
/>
)}
</View>
</SheetModal>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.75)',
overflow: 'hidden',
marginBottom: 8,
},
empty: {
color: 'rgba(94,42,40,0.55)',
fontSize: 15,
textAlign: 'center',
paddingVertical: 26,
paddingHorizontal: 14,
},
list: {
paddingBottom: 10,
},
row: {
paddingHorizontal: 14,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(94,42,40,0.10)',
backgroundColor: 'rgba(255,255,255,0.15)',
},
text: {
color: '#5E2A28',
fontSize: 15,
lineHeight: 22,
fontWeight: '600',
},
});

View File

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

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
export type ThemeMode = 'scenery' | 'color';
type Props = {
visible: boolean;
mode: ThemeMode;
onSelect: (mode: ThemeMode) => void;
onClose: () => void;
};
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
const { t } = useTranslation();
return (
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose}>
<View style={styles.row}>
<ThemeCard
title={t('theme.scenery')}
selected={mode === 'scenery'}
onPress={() => onSelect('scenery')}
>
<Image
source={require('../../assets/images/index/index_flowers.png')}
resizeMode="cover"
style={styles.previewImage}
/>
</ThemeCard>
<ThemeCard
title={t('theme.color')}
selected={mode === 'color'}
onPress={() => onSelect('color')}
>
<View style={styles.colorPreview} />
</ThemeCard>
</View>
</SheetModal>
);
}
function ThemeCard({
title,
selected,
onPress,
children,
}: {
title: string;
selected: boolean;
onPress: () => void;
children: React.ReactNode;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.card, selected ? styles.cardSelected : styles.cardUnselected]}
hitSlop={6}
>
<View style={styles.preview}>{children}</View>
<Text style={styles.cardTitle}>{title}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 14,
paddingBottom: 18,
},
card: {
flex: 1,
borderRadius: 18,
padding: 10,
backgroundColor: 'rgba(255,255,255,0.55)',
},
cardSelected: {
borderWidth: 2,
borderColor: '#F99CC0',
},
cardUnselected: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(94,42,40,0.18)',
},
preview: {
height: 96,
borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#fff',
marginBottom: 10,
},
previewImage: {
width: '100%',
height: '100%',
},
colorPreview: {
flex: 1,
backgroundColor: '#F3D0E1',
},
cardTitle: {
color: '#5E2A28',
fontSize: 14,
fontWeight: '700',
textAlign: 'center',
},
});

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
type Props = {
visible: boolean;
onClose: () => void;
};
export default function WidgetModal({ visible, onClose }: Props) {
const { t } = useTranslation();
return (
<SheetModal visible={visible} title={t('profile.widget')} onClose={onClose}>
<View style={styles.content}>
<View style={styles.row}>
<PreviewCard label={t('widget.lockScreen')}>
<View style={[styles.previewInner, styles.previewLock]}>
<Text style={styles.previewDate} numberOfLines={1}>
{t('widget.previewDate')}
</Text>
<Text style={styles.previewTime} numberOfLines={1}>
13:45
</Text>
</View>
</PreviewCard>
<PreviewCard label={t('widget.homeScreen')}>
<View style={[styles.previewInner, styles.previewHome]}>
<Text style={styles.previewQuote} numberOfLines={3}>
{t('widget.previewQuote')}
</Text>
</View>
</PreviewCard>
</View>
<Text style={styles.desc}>{t('settings.widgetDesc')}</Text>
</View>
</SheetModal>
);
}
function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<View style={styles.card}>
{children}
<Text style={styles.cardLabel}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
content: {
paddingBottom: 18,
gap: 14,
},
row: {
flexDirection: 'row',
gap: 12,
},
card: {
flex: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.75)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(94,42,40,0.12)',
},
previewInner: {
height: 120,
padding: 12,
justifyContent: 'center',
},
previewLock: {
backgroundColor: 'rgba(244,214,194,0.65)',
},
previewHome: {
backgroundColor: 'rgba(243,208,225,0.55)',
},
previewDate: {
color: 'rgba(94,42,40,0.70)',
fontSize: 12,
fontWeight: '600',
marginBottom: 6,
},
previewTime: {
color: '#ffffff',
fontSize: 44,
fontWeight: '800',
letterSpacing: 1,
},
previewQuote: {
color: '#5E2A28',
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
},
cardLabel: {
paddingVertical: 10,
textAlign: 'center',
color: '#5E2A28',
fontSize: 13,
fontWeight: '700',
},
desc: {
color: 'rgba(94,42,40,0.75)',
fontSize: 13,
lineHeight: 18,
},
});

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { SerifText } from './SerifText';
import { OnboardingColors } from '@/constants/OnboardingTheme';
export const INTENTS = [
{ id: 'love', label: '爱情', icon: '❤️' },
{ id: 'life', label: '生活', icon: '⛅' },
{ id: 'travel', label: '旅游', icon: '🌴' },
{ id: 'work', label: '职场', icon: '💼' },
];
interface IntentSelectionStepProps {
selectedIds: string[];
onToggle: (id: string) => void;
}
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
return (
<View style={styles.container}>
<SerifText style={styles.title}></SerifText>
<View style={styles.grid}>
{INTENTS.map((intent) => {
const isSelected = selectedIds.includes(intent.id);
return (
<TouchableOpacity
key={intent.id}
style={[
styles.card,
isSelected && styles.cardSelected
]}
onPress={() => onToggle(intent.id)}
activeOpacity={0.7}
>
<SerifText style={styles.icon}>{intent.icon}</SerifText>
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
{intent.label}
</SerifText>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
},
title: {
fontSize: 24,
marginBottom: 40,
textAlign: 'center',
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
justifyContent: 'center',
width: '100%',
},
card: {
width: '45%', // slightly less than half to accommodate gap
aspectRatio: 1.2,
backgroundColor: OnboardingColors.cardBackground,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
borderWidth: 2,
borderColor: 'transparent',
},
cardSelected: {
borderColor: OnboardingColors.buttonEnd, // Use pink as highlight
backgroundColor: '#FFFBF5',
},
icon: {
fontSize: 32,
},
label: {
fontSize: 18,
color: OnboardingColors.textPrimary,
},
labelSelected: {
fontWeight: 'bold',
},
});

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { View, StyleSheet, TextInput, Platform } from 'react-native';
import { SerifText } from './SerifText';
import { OnboardingColors } from '@/constants/OnboardingTheme';
interface NameInputStepProps {
value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
}
export function NameInputStep({ value, onChangeText, onSubmitEditing }: NameInputStepProps) {
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>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
},
title: {
fontSize: 24,
marginBottom: 40,
textAlign: 'center',
},
inputContainer: {
width: '100%',
backgroundColor: OnboardingColors.cardBackground,
borderRadius: 20,
paddingVertical: 20,
paddingHorizontal: 24,
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' }),
color: OnboardingColors.textPrimary,
textAlign: 'center',
padding: 0, // remove default padding
},
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Svg, { Path } from 'react-native-svg';
import { OnboardingColors } from '@/constants/OnboardingTheme';
interface NextButtonProps {
onPress: () => void;
disabled?: boolean;
style?: ViewStyle;
}
export function NextButton({ onPress, disabled, style }: NextButtonProps) {
return (
<TouchableOpacity onPress={onPress} disabled={disabled} activeOpacity={0.8} style={[styles.container, style]}>
<LinearGradient
colors={[OnboardingColors.buttonStart, OnboardingColors.buttonEnd]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.gradient, disabled && styles.disabled]}
>
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Path d="M5 12h14M12 5l7 7-7 7" />
</Svg>
</LinearGradient>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
shadowColor: OnboardingColors.buttonEnd,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 5,
},
gradient: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
},
disabled: {
opacity: 0.5,
},
});

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar } 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;
}
export function OnboardingLayout({
children,
onSkip,
onNext,
nextEnabled = true,
showNextButton = true
}: OnboardingLayoutProps) {
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
<SafeAreaView style={styles.safeArea}>
<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>
<View style={styles.footer}>
{showNextButton && onNext && (
<NextButton onPress={onNext} disabled={!nextEnabled} />
)}
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: OnboardingColors.background,
},
safeArea: {
flex: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 12,
},
spacer: {
width: 60, // Balance the skip button width approximately
},
skipButton: {
padding: 8,
},
skipText: {
fontSize: 16,
color: OnboardingColors.textPrimary,
},
content: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
},
footer: {
alignItems: 'center',
paddingBottom: 40,
minHeight: 100, // Reserve space for button
},
});

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Text, TextProps, Platform, StyleSheet } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme';
interface SerifTextProps extends TextProps {
weight?: 'regular' | 'bold';
}
export function SerifText({ style, weight = 'regular', ...otherProps }: SerifTextProps) {
return (
<Text
style={[
styles.text,
weight === 'bold' && styles.bold,
style,
]}
{...otherProps}
/>
);
}
const styles = StyleSheet.create({
text: {
fontFamily: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }),
color: OnboardingColors.textPrimary,
},
bold: {
fontWeight: 'bold',
},
});

View File

@@ -0,0 +1,138 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
type Props = {
visible: boolean;
title?: string;
onClose: () => void;
children: React.ReactNode;
};
/**
* 通用底部上拉弹窗Sheet
* - 内容区背景色固定:#FAF3EC
* - 关闭方式:点 X本期不要求点遮罩关闭
*/
export default function SheetModal({ visible, title, onClose, children }: Props) {
const insets = useSafeAreaInsets();
const [mounted, setMounted] = useState(false);
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
useEffect(() => {
if (visible) {
setMounted(true);
progress.value = withTiming(1, { duration: 260, easing: Easing.out(Easing.cubic) });
return;
}
if (!mounted) return;
progress.value = withTiming(
0,
{ duration: 220, easing: Easing.in(Easing.cubic) },
(finished) => {
if (finished) runOnJS(setMounted)(false);
}
);
}, [visible, mounted, progress]);
const overlayStyle = useAnimatedStyle(() => {
return { opacity: 0.4 * progress.value };
});
const sheetStyle = useAnimatedStyle(() => {
const translateY = (1 - progress.value) * 380;
return { transform: [{ translateY }] };
});
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 12), [insets.bottom]);
// 注意Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
return (
<Modal transparent visible={mounted} animationType="none" onRequestClose={onClose}>
<View style={styles.root}>
<Animated.View style={[styles.overlay, overlayStyle]} />
<Animated.View
style={[
styles.sheet,
sheetStyle,
{
paddingBottom: containerPaddingBottom,
},
]}
>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1}>
{title ?? ''}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="关闭"
onPress={onClose}
hitSlop={10}
style={styles.close}
>
<Text style={styles.closeText}>×</Text>
</Pressable>
</View>
<View style={styles.body}>{children}</View>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
justifyContent: 'flex-end',
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#000',
},
sheet: {
backgroundColor: '#FAF3EC',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 14,
paddingHorizontal: 16,
},
header: {
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
title: {
color: '#5E2A28',
fontSize: 16,
fontWeight: '700',
},
close: {
position: 'absolute',
left: 4,
top: 0,
height: 44,
width: 44,
alignItems: 'center',
justifyContent: 'center',
},
closeText: {
color: '#5E2A28',
fontSize: 28,
lineHeight: 28,
fontWeight: '400',
},
body: {
paddingTop: 10,
},
});