feature:基本功能
This commit is contained in:
187
client/components/home/DailyReminderModal.tsx
Normal file
187
client/components/home/DailyReminderModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
|
||||
89
client/components/home/FavoritesModal.tsx
Normal file
89
client/components/home/FavoritesModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
|
||||
662
client/components/home/ProfileModal.tsx
Normal file
662
client/components/home/ProfileModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
110
client/components/home/ThemeModal.tsx
Normal file
110
client/components/home/ThemeModal.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
|
||||
113
client/components/home/WidgetModal.tsx
Normal file
113
client/components/home/WidgetModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
95
client/components/onboarding/IntentSelectionStep.tsx
Normal file
95
client/components/onboarding/IntentSelectionStep.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
64
client/components/onboarding/NameInputStep.tsx
Normal file
64
client/components/onboarding/NameInputStep.tsx
Normal 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
|
||||
},
|
||||
});
|
||||
48
client/components/onboarding/NextButton.tsx
Normal file
48
client/components/onboarding/NextButton.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
84
client/components/onboarding/OnboardingLayout.tsx
Normal file
84
client/components/onboarding/OnboardingLayout.tsx
Normal 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
|
||||
},
|
||||
});
|
||||
30
client/components/onboarding/SerifText.tsx
Normal file
30
client/components/onboarding/SerifText.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
138
client/components/ui/SheetModal.tsx
Normal file
138
client/components/ui/SheetModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user