diff --git a/client/app/(app)/_layout.tsx b/client/app/(app)/_layout.tsx index 854f3ba..364b025 100644 --- a/client/app/(app)/_layout.tsx +++ b/client/app/(app)/_layout.tsx @@ -1,10 +1,7 @@ import { Stack } from 'expo-router'; -import { Pressable, Text } from 'react-native'; import { useTranslation } from 'react-i18next'; -import { useRouter } from 'expo-router'; export default function AppLayout() { - const router = useRouter(); const { t } = useTranslation(); return ( @@ -16,26 +13,9 @@ export default function AppLayout() { ( - router.push('/(app)/settings')} - hitSlop={10} - > - {t('home.settings')} - - ), - headerLeft: () => ( - router.push('/(app)/favorites')} hitSlop={10}> - {t('home.favorites')} - - ), - }} - /> - ([]); - - useEffect(() => { - let cancelled = false; - (async () => { - const list = await getFavorites(); - if (!cancelled) setIds(list); - })(); - return () => { - cancelled = true; - }; - }, []); - - 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 ( - - {items.length === 0 ? ( - {t('favorites.empty')} - ) : ( - it.id} - contentContainerStyle={styles.list} - renderItem={({ item }) => ( - - {item.text} - - )} - /> - )} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - empty: { color: '#6B7280', fontSize: 16, textAlign: 'center', marginTop: 40 }, - list: { gap: 12, paddingBottom: 24 }, - row: { - borderRadius: 14, - padding: 16, - backgroundColor: '#F9FAFB', - borderWidth: StyleSheet.hairlineWidth, - borderColor: '#E5E7EB', - }, - text: { color: '#111827', fontSize: 16, lineHeight: 22 }, -}); - diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index baa428e..9a81838 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -1,45 +1,220 @@ -import { useMemo, useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Pressable } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { useNavigation } from 'expo-router'; +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated'; import { MOCK_CONTENT } from '@/src/constants/mockContent'; -import { addFavorite, setReaction } from '@/src/storage/appStorage'; +import { + addFavorite, + getThemeMode, + getUserProfile, + setReaction, + setThemeMode, + type ThemeMode, +} from '@/src/storage/appStorage'; + +import ProfileModal from '@/components/home/ProfileModal'; +import ThemeModal from '@/components/home/ThemeModal'; + +import ThemeIcon from '@/assets/images/home/theme.svg'; +import MyIcon from '@/assets/images/home/my.svg'; +import LikeOutlineIcon from '@/assets/images/home/like.svg'; +import LikeFilledIcon from '@/assets/images/home/like_filled.svg'; +import HateIcon from '@/assets/images/home/hate.svg'; export default function HomeScreen() { const { t } = useTranslation(); + const navigation = useNavigation(); const [index, setIndex] = useState(0); + const [themeMode, setThemeModeState] = useState('scenery'); + const [themeOpen, setThemeOpen] = useState(false); + const [profileOpen, setProfileOpen] = useState(false); + const [profileName, setProfileName] = useState(undefined); + const [busy, setBusy] = useState(false); + const [likeFilled, setLikeFilled] = useState(false); const item = useMemo(() => MOCK_CONTENT[index % MOCK_CONTENT.length], [index]); - async function onLike() { - await setReaction(item.id, 'like'); - await addFavorite(item.id); - setIndex((i) => i + 1); + useEffect(() => { + let cancelled = false; + (async () => { + const mode = await getThemeMode(); + const profile = await getUserProfile(); + if (cancelled) return; + setThemeModeState(mode); + setProfileName(profile.name); + })(); + return () => { + cancelled = true; + }; + }, []); + + const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2'; + + useLayoutEffect(() => { + navigation.setOptions({ + headerShadowVisible: false, + headerStyle: { backgroundColor }, + headerRight: () => ( + + setThemeOpen(true)} + accessibilityLabel={t('home.theme')} + > + + + setProfileOpen(true)} + accessibilityLabel={t('home.profile')} + > + + + + ), + }); + }, [backgroundColor, navigation, t]); + + const likeScale = useSharedValue(1); + const hateScale = useSharedValue(1); + + const likeAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: likeScale.value }], + })); + + const hateAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: hateScale.value }], + })); + + function playLikeAnimationAndThen(next: () => void) { + setLikeFilled(true); + likeScale.value = withSequence( + withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }), + withTiming(1.14, { duration: 120, easing: Easing.out(Easing.cubic) }), + withTiming(1, { duration: 140, easing: Easing.out(Easing.cubic) }, (finished) => { + if (finished) runOnJS(next)(); + }) + ); } - async function onDislike() { - await setReaction(item.id, 'dislike'); - setIndex((i) => i + 1); + function playHateAnimationAndThen(next: () => void) { + hateScale.value = withSequence( + withTiming(0.92, { duration: 90, easing: Easing.out(Easing.cubic) }), + withTiming(1.06, { duration: 110, easing: Easing.out(Easing.cubic) }), + withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) }, (finished) => { + if (finished) runOnJS(next)(); + }) + ); + } + + function onPressLike() { + if (busy) return; + setBusy(true); + playLikeAnimationAndThen(async () => { + await setReaction(item.id, 'like'); + await addFavorite(item.id); + setIndex((i) => i + 1); + setTimeout(() => setLikeFilled(false), 220); + setBusy(false); + }); + } + + function onPressHate() { + if (busy) return; + setBusy(true); + playHateAnimationAndThen(async () => { + await setReaction(item.id, 'dislike'); + setIndex((i) => i + 1); + setBusy(false); + }); + } + + async function onSelectTheme(next: ThemeMode) { + setThemeModeState(next); + await setThemeMode(next); + setThemeOpen(false); } return ( - + - {item.text} + {item.text} - - {t('home.dislike')} - - - {t('home.like')} - + + (hateScale.value = withTiming(0.92, { duration: 80 }))} + onPressOut={() => (hateScale.value = withTiming(1, { duration: 120 }))} + accessibilityRole="button" + accessibilityLabel={t('home.dislike')} + hitSlop={10} + style={styles.reactionInner} + > + + + + + + (likeScale.value = withTiming(0.92, { duration: 80 }))} + onPressOut={() => (likeScale.value = withTiming(1, { duration: 120 }))} + accessibilityRole="button" + accessibilityLabel={t('home.like')} + hitSlop={10} + style={styles.reactionInner} + > + {likeFilled ? ( + + ) : ( + + )} + + + + setThemeOpen(false)} /> + setProfileOpen(false)} + /> ); } +function CircleIconButton({ + onPress, + accessibilityLabel, + children, +}: { + onPress: () => void; + accessibilityLabel: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + const styles = StyleSheet.create({ container: { flex: 1, @@ -47,30 +222,51 @@ const styles = StyleSheet.create({ justifyContent: 'center', gap: 16, }, + headerRight: { + flexDirection: 'row', + gap: 10, + paddingRight: 10, + }, + circleBtn: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: 'rgba(255,255,255,0.75)', + alignItems: 'center', + justifyContent: 'center', + }, card: { borderRadius: 16, padding: 20, - backgroundColor: '#F6F7FB', + backgroundColor: 'rgba(255,255,255,0.65)', borderWidth: StyleSheet.hairlineWidth, borderColor: '#E5E7EB', }, text: { fontSize: 20, lineHeight: 28, - color: '#111827', + color: '#5E2A28', + fontWeight: '700', }, actions: { flexDirection: 'row', gap: 12, + justifyContent: 'center', }, - button: { - flex: 1, - paddingVertical: 14, - borderRadius: 14, + reactionButton: { + width: 58, + height: 58, + borderRadius: 29, + backgroundColor: 'rgba(255,255,255,0.75)', alignItems: 'center', + justifyContent: 'center', + }, + reactionInner: { + width: 58, + height: 58, + borderRadius: 29, + alignItems: 'center', + justifyContent: 'center', }, - like: { backgroundColor: '#16A34A' }, - dislike: { backgroundColor: '#EF4444' }, - buttonText: { color: 'white', fontSize: 16, fontWeight: '600' }, }); diff --git a/client/app/(onboarding)/_layout.tsx b/client/app/(onboarding)/_layout.tsx index 916954e..a18c337 100644 --- a/client/app/(onboarding)/_layout.tsx +++ b/client/app/(onboarding)/_layout.tsx @@ -8,6 +8,7 @@ export default function OnboardingLayout() { diff --git a/client/app/(onboarding)/onboarding.tsx b/client/app/(onboarding)/onboarding.tsx index 84e2a06..e95d54a 100644 --- a/client/app/(onboarding)/onboarding.tsx +++ b/client/app/(onboarding)/onboarding.tsx @@ -1,104 +1,68 @@ -import { useMemo, useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useState } from 'react'; import { useRouter } from 'expo-router'; -import { useTranslation } from 'react-i18next'; - -import { setOnboardingCompleted } from '@/src/storage/appStorage'; - -type OnboardingPage = { - title: string; - desc: string; -}; +import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout'; +import { NameInputStep } from '@/components/onboarding/NameInputStep'; +import { IntentSelectionStep } from '@/components/onboarding/IntentSelectionStep'; +import { setOnboardingCompleted, setUserProfile } from '@/src/storage/appStorage'; export default function OnboardingScreen() { const router = useRouter(); - const { t } = useTranslation(); - - // 3–5 页:这里默认 4 页,后续可按产品调整为 3 或 5 - const pages = useMemo( - () => [ - { title: t('onboarding.q1Title'), desc: t('onboarding.q1Desc') }, - { title: t('onboarding.q2Title'), desc: t('onboarding.q2Desc') }, - { title: t('onboarding.q3Title'), desc: t('onboarding.q3Desc') }, - { title: t('onboarding.q4Title'), desc: t('onboarding.q4Desc') } - ], - [t] - ); - - const total = pages.length; const [step, setStep] = useState(0); - const page = pages[Math.min(step, total - 1)]; + const [name, setName] = useState(''); + const [intents, setIntents] = useState([]); - async function finishAndNext() { + async function onFinish() { + // Save whatever input we have + await setUserProfile({ name, intents }); await setOnboardingCompleted(true); router.replace('/(onboarding)/push-prompt'); } async function onNext() { - if (step >= total - 1) { - await finishAndNext(); - return; + if (step === 0) { + setStep(1); + } else { + await onFinish(); } - setStep((s) => s + 1); } - async function onSkipPage() { - // 每页可跳过:直接进入下一页(最后一页则结束) - await onNext(); + async function onSkip() { + // Skip logic: move to next step regardless of input + if (step === 0) { + setStep(1); + } else { + await onFinish(); + } } - async function onSkipAll() { - // 一键跳过整个 Onboarding - await finishAndNext(); - } + // Step 0: Next button requires input + // Step 1: Next button always enabled (can proceed with empty selection) + const nextEnabled = step === 0 ? name.trim().length > 0 : true; return ( - - - {t('onboarding.progress', { current: step + 1, total })} - - - - {page.title} - {page.desc} - - - - - {t('onboarding.skip')} - - - {t('onboarding.next')} - - - - - {t('onboarding.skipAll')} - - + + {step === 0 ? ( + 0 ? onNext : undefined} + /> + ) : ( + { + setIntents(prev => + prev.includes(id) + ? prev.filter(i => i !== id) + : [...prev, id] + ); + }} + /> + )} + ); } - -const styles = StyleSheet.create({ - container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 }, - progress: { textAlign: 'center', color: '#6B7280' }, - card: { - borderRadius: 18, - padding: 20, - backgroundColor: '#FFFFFF', - borderWidth: StyleSheet.hairlineWidth, - borderColor: '#E5E7EB', - gap: 10 - }, - title: { fontSize: 22, fontWeight: '700', color: '#111827' }, - desc: { fontSize: 16, lineHeight: 22, color: '#374151' }, - actions: { flexDirection: 'row', gap: 12 }, - btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' }, - primary: { backgroundColor: '#111827' }, - secondary: { backgroundColor: '#F3F4F6' }, - btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' }, - secondaryText: { color: '#111827' }, - skipAll: { alignItems: 'center', paddingTop: 10 }, - skipAllText: { color: '#6B7280', textDecorationLine: 'underline' } -}); - diff --git a/client/app/(splash)/_layout.tsx b/client/app/(splash)/_layout.tsx new file mode 100644 index 0000000..400dc99 --- /dev/null +++ b/client/app/(splash)/_layout.tsx @@ -0,0 +1,10 @@ +import { Stack } from 'expo-router'; +import React from 'react'; + +export default function SplashLayout() { + return ( + + + + ); +} diff --git a/client/app/(splash)/splash.tsx b/client/app/(splash)/splash.tsx new file mode 100644 index 0000000..b59e3a6 --- /dev/null +++ b/client/app/(splash)/splash.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, Image, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { useTranslation } from 'react-i18next'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage'; + +const { width, height } = Dimensions.get('window'); + +export default function SplashScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const [showConsent, setShowConsent] = useState(false); + + useEffect(() => { + checkConsent(); + }, []); + + const checkConsent = async () => { + const accepted = await getConsentAccepted(); + setShowConsent(!accepted); + if (accepted) { + // 如果已经同意过,直接跳转到首页分发 + // 注意:这里需要与 app/index.tsx 配合,如果 app/index.tsx 已经判断了 consent=true 不会跳过来, + // 那么这里其实是防守。 + // 但如果用户是通过 deep link 或其他方式强制进入 splash,这个逻辑会把他们送走。 + router.replace('/'); + } + }; + + const handleAgree = async () => { + await setConsentAccepted(true); + setShowConsent(false); + router.replace('/'); + }; + + const openLink = async (url: string) => { + try { + await WebBrowser.openBrowserAsync(url); + } catch (error) { + Alert.alert(t('common.error'), t('common.openLinkError')); + } + }; + + return ( + + + + + {t('consent.title')} + {t('consent.subtitle')} + + + + {showConsent && ( + <> + + + {t('consent.agree')} + + + + + openLink('https://example.com/privacy')}> + {t('consent.privacy')} + + + openLink('https://example.com/terms')}> + {t('consent.terms')} + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFF9F0', // 假设的背景色,根据图片调整 + alignItems: 'center', + }, + image: { + width: width * 0.8, + height: height * 0.5, + marginTop: height * 0.1, + }, + contentContainer: { + alignItems: 'center', + marginTop: 20, + paddingHorizontal: 30, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#4A3427', // 深褐色 + marginBottom: 10, + textAlign: 'center', + fontFamily: Platform.OS === 'ios' ? 'Georgia' : 'serif', // 尝试匹配衬线体 + }, + subtitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#4A3427', // 深褐色 + textAlign: 'center', + lineHeight: 24, + fontFamily: Platform.OS === 'ios' ? 'Georgia' : 'serif', + }, + bottomContainer: { + position: 'absolute', + bottom: 0, + width: '100%', + alignItems: 'center', + paddingBottom: 20, + }, + button: { + width: width * 0.8, + paddingVertical: 16, + borderRadius: 30, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 20, + shadowColor: '#F69F7B', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 5, + }, + buttonText: { + color: '#FFF', + fontSize: 18, + fontWeight: '600', + }, + linksContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + linkText: { + fontSize: 12, + color: '#999', + textDecorationLine: 'underline', + }, + divider: { + width: 1, + height: 12, + backgroundColor: '#CCC', + marginHorizontal: 15, + }, +}); diff --git a/client/app/index.tsx b/client/app/index.tsx index ddfcb92..6650daf 100644 --- a/client/app/index.tsx +++ b/client/app/index.tsx @@ -2,10 +2,10 @@ import { useEffect } from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { useRouter } from 'expo-router'; -import { getOnboardingCompleted } from '@/src/storage/appStorage'; +import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage'; /** - * 启动分发:根据 onboarding 状态跳转 + * 启动分发:根据 consent 和 onboarding 状态跳转 */ export default function Index() { const router = useRouter(); @@ -13,8 +13,17 @@ export default function Index() { useEffect(() => { let cancelled = false; (async () => { - const completed = await getOnboardingCompleted(); + // 1. 检查是否同意协议 + const consentAccepted = await getConsentAccepted(); if (cancelled) return; + + if (!consentAccepted) { + router.replace('/(splash)/splash'); + return; + } + + // 2. 检查 Onboarding + const completed = await getOnboardingCompleted(); router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding'); })(); return () => { diff --git a/client/assets/images/home/Profile/Default_avatar.png b/client/assets/images/home/Profile/Default_avatar.png new file mode 100644 index 0000000..d8aa749 Binary files /dev/null and b/client/assets/images/home/Profile/Default_avatar.png differ diff --git a/client/assets/images/home/Profile/language.svg b/client/assets/images/home/Profile/language.svg new file mode 100644 index 0000000..61a2d78 --- /dev/null +++ b/client/assets/images/home/Profile/language.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/Profile/mylike.svg b/client/assets/images/home/Profile/mylike.svg new file mode 100644 index 0000000..59ef6ce --- /dev/null +++ b/client/assets/images/home/Profile/mylike.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/Profile/privacy Policy.svg b/client/assets/images/home/Profile/privacy Policy.svg new file mode 100644 index 0000000..08db4a1 --- /dev/null +++ b/client/assets/images/home/Profile/privacy Policy.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/Profile/remind.svg b/client/assets/images/home/Profile/remind.svg new file mode 100644 index 0000000..cd08c3f --- /dev/null +++ b/client/assets/images/home/Profile/remind.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/Profile/small_component.svg b/client/assets/images/home/Profile/small_component.svg new file mode 100644 index 0000000..e9184b5 --- /dev/null +++ b/client/assets/images/home/Profile/small_component.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/assets/images/home/Profile/terms_of_use.svg b/client/assets/images/home/Profile/terms_of_use.svg new file mode 100644 index 0000000..3a7c24e --- /dev/null +++ b/client/assets/images/home/Profile/terms_of_use.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/hate.svg b/client/assets/images/home/hate.svg new file mode 100644 index 0000000..1a6663e --- /dev/null +++ b/client/assets/images/home/hate.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/like.svg b/client/assets/images/home/like.svg new file mode 100644 index 0000000..f88951c --- /dev/null +++ b/client/assets/images/home/like.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/home/like_filled.svg b/client/assets/images/home/like_filled.svg new file mode 100644 index 0000000..f8749df --- /dev/null +++ b/client/assets/images/home/like_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/assets/images/home/my.svg b/client/assets/images/home/my.svg new file mode 100644 index 0000000..3755daf --- /dev/null +++ b/client/assets/images/home/my.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/assets/images/home/theme.svg b/client/assets/images/home/theme.svg new file mode 100644 index 0000000..9b5763e --- /dev/null +++ b/client/assets/images/home/theme.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/assets/images/index/index_flowers.png b/client/assets/images/index/index_flowers.png new file mode 100644 index 0000000..5e9793a Binary files /dev/null and b/client/assets/images/index/index_flowers.png differ diff --git a/client/components/home/DailyReminderModal.tsx b/client/components/home/DailyReminderModal.tsx new file mode 100644 index 0000000..b835212 --- /dev/null +++ b/client/components/home/DailyReminderModal.tsx @@ -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 ( + + + setTimesPerDay((v) => clamp(v - 1))} + hitSlop={10} + style={styles.circleBtn} + accessibilityRole="button" + accessibilityLabel={t('dailyReminder.minus')} + > + + + + + {timesPerDay} + {t('dailyReminder.timesUnit')} + + + setTimesPerDay((v) => clamp(v + 1))} + hitSlop={10} + style={styles.circleBtn} + accessibilityRole="button" + accessibilityLabel={t('dailyReminder.plus')} + > + + + + + + + + + + {t('dailyReminder.pushLabel')} + + + + + + + {t('dailyReminder.ok')} + + + + ); +} + +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', + }, +}); + diff --git a/client/components/home/FavoritesModal.tsx b/client/components/home/FavoritesModal.tsx new file mode 100644 index 0000000..53f2b49 --- /dev/null +++ b/client/components/home/FavoritesModal.tsx @@ -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([]); + + // 每次打开时刷新一次,确保展示最新“喜欢” + 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 ( + + + {items.length === 0 ? ( + {t('favorites.empty')} + ) : ( + it.id} + contentContainerStyle={styles.list} + renderItem={({ item }) => ( + + {item.text} + + )} + /> + )} + + + ); +} + +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', + }, +}); + diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx new file mode 100644 index 0000000..dc32fd6 --- /dev/null +++ b/client/components/home/ProfileModal.tsx @@ -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('root'); + const [navDirection, setNavDirection] = useState('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 ( + + {!isRoot && ( + go('root', 'back')} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.back')} + style={styles.backRow} + > + ‹ {t('common.back')} + + )} + + + + {page === 'root' ? ( + go('favorites', 'forward')} + onOpenWidget={() => go('widget', 'forward')} + onOpenDailyReminder={() => go('dailyReminder', 'forward')} + /> + ) : page === 'favorites' ? ( + + ) : page === 'dailyReminder' ? ( + go('root', 'back')} /> + ) : ( + + )} + + + + ); +} + +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 ( + <> + + + {name || 'Hali'} + + + + + + + + + + + + + } + title={t('profile.dailyReminder')} + onPress={onOpenDailyReminder} + /> + } + title={t('profile.privacy')} + onPress={() => toastTodo(t)} + /> + } + title={t('profile.terms')} + onPress={() => toastTodo(t)} + /> + } + title={t('profile.language')} + onPress={() => toastTodo(t)} + /> + + + ); +} + +function FavoritesPage({ visible }: { visible: boolean }) { + const { t } = useTranslation(); + const [ids, setIds] = useState([]); + + // 每次进入该页刷新一次,确保展示最新“喜欢” + 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 ( + + {items.length === 0 ? ( + {t('favorites.empty')} + ) : ( + it.id} + contentContainerStyle={styles.favList} + renderItem={({ item }) => ( + + {item.text} + + )} + /> + )} + + ); +} + +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 ( + <> + + setTimesPerDay((v) => clamp(v - 1))} + hitSlop={10} + style={styles.circleBtn} + accessibilityRole="button" + accessibilityLabel={t('dailyReminder.minus')} + > + + + + + {timesPerDay} + {t('dailyReminder.timesUnit')} + + + setTimesPerDay((v) => clamp(v + 1))} + hitSlop={10} + style={styles.circleBtn} + accessibilityRole="button" + accessibilityLabel={t('dailyReminder.plus')} + > + + + + + + + + + + {t('dailyReminder.pushLabel')} + + + + + + + {t('dailyReminder.ok')} + + + + ); +} + +function WidgetPage() { + const { t } = useTranslation(); + return ( + + + + + + {t('widget.previewDate')} + + + 13:45 + + + {t('widget.lockScreen')} + + + + + + {t('widget.previewQuote')} + + + {t('widget.homeScreen')} + + + + {t('settings.widgetDesc')} + + ); +} + +function QuickCard({ + title, + onPress, + children, +}: { + title: string; + onPress: () => void; + children: React.ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +function ListItem({ + icon, + title, + onPress, +}: { + icon: React.ReactNode; + title: string; + onPress: () => void; +}) { + return ( + + + {icon} + {title} + + + + ); +} + +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, + }, +}); + diff --git a/client/components/home/ThemeModal.tsx b/client/components/home/ThemeModal.tsx new file mode 100644 index 0000000..d5f9b59 --- /dev/null +++ b/client/components/home/ThemeModal.tsx @@ -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 ( + + + onSelect('scenery')} + > + + + + onSelect('color')} + > + + + + + ); +} + +function ThemeCard({ + title, + selected, + onPress, + children, +}: { + title: string; + selected: boolean; + onPress: () => void; + children: React.ReactNode; +}) { + return ( + + {children} + {title} + + ); +} + +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', + }, +}); + diff --git a/client/components/home/WidgetModal.tsx b/client/components/home/WidgetModal.tsx new file mode 100644 index 0000000..539cb44 --- /dev/null +++ b/client/components/home/WidgetModal.tsx @@ -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 ( + + + + + + + {t('widget.previewDate')} + + + 13:45 + + + + + + + + {t('widget.previewQuote')} + + + + + + {t('settings.widgetDesc')} + + + ); +} + +function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + {children} + {label} + + ); +} + +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, + }, +}); + diff --git a/client/components/onboarding/IntentSelectionStep.tsx b/client/components/onboarding/IntentSelectionStep.tsx new file mode 100644 index 0000000..89b6974 --- /dev/null +++ b/client/components/onboarding/IntentSelectionStep.tsx @@ -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 ( + + 你希望得到什么帮助? + + + {INTENTS.map((intent) => { + const isSelected = selectedIds.includes(intent.id); + return ( + onToggle(intent.id)} + activeOpacity={0.7} + > + {intent.icon} + + {intent.label} + + + ); + })} + + + ); +} + +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', + }, +}); diff --git a/client/components/onboarding/NameInputStep.tsx b/client/components/onboarding/NameInputStep.tsx new file mode 100644 index 0000000..beaddcb --- /dev/null +++ b/client/components/onboarding/NameInputStep.tsx @@ -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 ( + + 我可以怎么称呼你? + + + + + + ); +} + +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 + }, +}); diff --git a/client/components/onboarding/NextButton.tsx b/client/components/onboarding/NextButton.tsx new file mode 100644 index 0000000..b636ac2 --- /dev/null +++ b/client/components/onboarding/NextButton.tsx @@ -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 ( + + + + + + + + ); +} + +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, + }, +}); diff --git a/client/components/onboarding/OnboardingLayout.tsx b/client/components/onboarding/OnboardingLayout.tsx new file mode 100644 index 0000000..33a014e --- /dev/null +++ b/client/components/onboarding/OnboardingLayout.tsx @@ -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 ( + + + + + + {onSkip && ( + + Skip {'->'} + + )} + + + + {children} + + + + {showNextButton && onNext && ( + + )} + + + + ); +} + +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 + }, +}); diff --git a/client/components/onboarding/SerifText.tsx b/client/components/onboarding/SerifText.tsx new file mode 100644 index 0000000..2e42e63 --- /dev/null +++ b/client/components/onboarding/SerifText.tsx @@ -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 ( + + ); +} + +const styles = StyleSheet.create({ + text: { + fontFamily: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }), + color: OnboardingColors.textPrimary, + }, + bold: { + fontWeight: 'bold', + }, +}); diff --git a/client/components/ui/SheetModal.tsx b/client/components/ui/SheetModal.tsx new file mode 100644 index 0000000..e0fc3ea --- /dev/null +++ b/client/components/ui/SheetModal.tsx @@ -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 ( + + + + + + + + {title ?? ''} + + + × + + + + {children} + + + + ); +} + +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, + }, +}); + diff --git a/client/constants/OnboardingTheme.ts b/client/constants/OnboardingTheme.ts new file mode 100644 index 0000000..7d8354a --- /dev/null +++ b/client/constants/OnboardingTheme.ts @@ -0,0 +1,9 @@ +export const OnboardingColors = { + background: '#FFF4EA', + textPrimary: '#4A3B32', + textSecondary: '#8C8C8C', + buttonStart: '#F69F7B', + buttonEnd: '#F99CC0', + cardBackground: '#FFFFFF', + cursor: '#F99CC0', +}; diff --git a/client/ios/Podfile.lock b/client/ios/Podfile.lock index 6d6fe2c..23e87b7 100644 --- a/client/ios/Podfile.lock +++ b/client/ios/Podfile.lock @@ -41,6 +41,8 @@ PODS: - RNScreens - ExpoKeepAwake (15.0.8): - ExpoModulesCore + - ExpoLinearGradient (15.0.8): + - ExpoModulesCore - ExpoLinking (8.0.11): - ExpoModulesCore - ExpoLocalization (17.0.8): @@ -1917,6 +1919,51 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - RNSVG (15.12.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNSVG/common (= 15.12.1) + - Yoga + - RNSVG/common (15.12.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - RNWorklets (0.5.1): - hermes-engine - RCTRequired @@ -2000,6 +2047,7 @@ DEPENDENCIES: - "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)" - "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)" - "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)" + - "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)" - "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)" - "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)" - "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)" @@ -2076,6 +2124,7 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)" - "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)" - "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)" + - "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)" - "RNWorklets (from `../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets`)" - "Yoga (from `../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native/ReactCommon/yoga`)" @@ -2098,6 +2147,8 @@ EXTERNAL SOURCES: :path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios" ExpoKeepAwake: :path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios" + ExpoLinearGradient: + :path: "../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios" ExpoLinking: :path: "../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios" ExpoLocalization: @@ -2249,6 +2300,8 @@ EXTERNAL SOURCES: :path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens" + RNSVG: + :path: "../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg" RNWorklets: :path: "../node_modules/.pnpm/react-native-worklets@0.5.1_@babel+core@7.28.6_react-native@0.81.5_@babel+core@7.28.6_@types+_5atwepuw3zy3crkgvetf35tkve/node_modules/react-native-worklets" Yoga: @@ -2264,6 +2317,7 @@ SPEC CHECKSUMS: ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664 ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29 ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0 + ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86 ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959 ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485 ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798 @@ -2339,6 +2393,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 + RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index a5c532d..4a11c49 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -11,7 +11,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; - A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; }; + A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; }; B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; }; @@ -50,7 +50,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = ""; }; 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = ""; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = ""; }; @@ -66,7 +66,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( EmotionWidget.swift, @@ -77,7 +77,18 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = ""; }; + EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = "情绪小组件"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -189,7 +200,7 @@ EB3DAFD42F2A5FC100450593 /* Recovered References */ = { isa = PBXGroup; children = ( - A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */, + A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -245,8 +256,6 @@ EB3DAF842F2A4B8E00450593 /* 情绪小组件 */, ); name = "情绪小组件Extension"; - packageProductDependencies = ( - ); productName = "情绪小组件Extension"; productReference = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; productType = "com.apple.product-type.app-extension"; @@ -362,6 +371,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", ); @@ -374,6 +384,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", ); @@ -444,7 +455,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -683,6 +694,7 @@ MARKETING_VERSION = 1.0.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -733,6 +745,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0.0; MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/client/ios/情绪小组件/Info.plist b/client/ios/情绪小组件/Info.plist index 0f118fb..d1a218a 100644 --- a/client/ios/情绪小组件/Info.plist +++ b/client/ios/情绪小组件/Info.plist @@ -7,5 +7,7 @@ NSExtensionPointIdentifier com.apple.widgetkit-extension + RCTNewArchEnabled + diff --git a/client/metro.config.js b/client/metro.config.js new file mode 100644 index 0000000..0e691ea --- /dev/null +++ b/client/metro.config.js @@ -0,0 +1,20 @@ +// Metro 配置:支持 import 本地 .svg 为 React 组件 +// 说明:Expo SDK 54 + react-native-svg-transformer 的常见配置方式 +const { getDefaultConfig } = require('expo/metro-config'); + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getDefaultConfig(__dirname); + +config.transformer = { + ...config.transformer, + babelTransformerPath: require.resolve('react-native-svg-transformer'), +}; + +config.resolver = { + ...config.resolver, + assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'), + sourceExts: [...config.resolver.sourceExts, 'svg'], +}; + +module.exports = config; + diff --git a/client/package.json b/client/package.json index 2c76288..071d3dc 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "expo": "~54.0.32", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", + "expo-linear-gradient": "^15.0.8", "expo-linking": "~8.0.11", "expo-localization": "^17.0.8", "expo-notifications": "^0.32.16", @@ -31,6 +32,8 @@ "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1", + "react-native-svg-transformer": "^1.5.3", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 58aabdd..160f3d6 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: expo-font: specifier: ~14.0.11 version: 14.0.11(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + expo-linear-gradient: + specifier: ^15.0.8 + version: 15.0.8(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-linking: specifier: ~8.0.11 version: 8.0.11(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -71,6 +74,12 @@ importers: react-native-screens: specifier: ~4.16.0 version: 4.16.0(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg: + specifier: 15.12.1 + version: 15.12.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg-transformer: + specifier: ^1.5.3 + version: 1.5.3(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3) react-native-web: specifier: ~0.21.0 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1138,6 +1147,84 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/plugin-svgo@8.1.0': + resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1362,6 +1449,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + boolbase@1.0.0: + resolution: {integrity: sha1-aN/1++YMUes3cl6p4+0xDcwed24=} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -1413,6 +1503,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M=} + engines: {node: '>=6'} + camelcase@5.3.1: resolution: {integrity: sha1-48mzFWnhBoEd8kL3FXJaH0xJQyA=} engines: {node: '>=6'} @@ -1526,6 +1620,15 @@ packages: core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -1540,6 +1643,29 @@ packages: css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1610,6 +1736,22 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-case@3.0.4: + resolution: {integrity: sha1-mytnDQCkMWZ6inW6Kc0bmICc51E=} + dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} @@ -1639,10 +1781,17 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + env-editor@0.4.2: resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} engines: {node: '>=8'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -1730,6 +1879,13 @@ packages: expo: '*' react: '*' + expo-linear-gradient@15.0.8: + resolution: {integrity: sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-linking@8.0.11: resolution: {integrity: sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==} peerDependencies: @@ -2030,6 +2186,10 @@ packages: engines: {node: '>=16.x'} hasBin: true + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + imurmurhash@0.1.4: resolution: {integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o=} engines: {node: '>=0.8.19'} @@ -2053,6 +2213,9 @@ packages: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} @@ -2170,6 +2333,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha1-fEeAWpQxmSjgV3dAXcEuH3pO4C0=} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2289,6 +2455,9 @@ packages: resolution: {integrity: sha1-ce5R+nvkyuwaY4OffmgtgTLTDK8=} hasBin: true + lower-case@2.0.2: + resolution: {integrity: sha1-b6I3xj29xKgsoP2ILkci3F5jTig=} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2309,6 +2478,15 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.0.14: + resolution: {integrity: sha1-cRP8QoGRfWPOKbQ0RvcB5owlulA=} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -2457,6 +2635,9 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha1-0syfxSNd2zcfxE1QYjQznI5LCks=} + no-case@3.0.4: + resolution: {integrity: sha1-02H9XJgA9VhVGoNp/A3NRmK2Ek0=} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2484,6 +2665,9 @@ packages: resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} engines: {node: ^16.14.0 || >=18.0.0} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nullthrows@1.1.1: resolution: {integrity: sha1-eBgliEOFaulx6uQgitfX6xmkMbE=} @@ -2558,6 +2742,14 @@ packages: resolution: {integrity: sha1-yyhoVA4xPWHeWPr741zpAE1VQOY=} engines: {node: '>=6'} + parent-module@1.0.1: + resolution: {integrity: sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI=} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse-png@2.1.0: resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} engines: {node: '>=10'} @@ -2566,6 +2758,9 @@ packages: resolution: {integrity: sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=} engines: {node: '>= 0.8'} + path-dirname@1.0.2: + resolution: {integrity: sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=} + path-exists@4.0.0: resolution: {integrity: sha1-UTvb4tO5XXdi6METfvoZXGxhtbM=} engines: {node: '>=8'} @@ -2585,6 +2780,10 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-type@4.0.0: + resolution: {integrity: sha1-hO0BwKe6OAr+CdkKjBgNzZ0DBDs=} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2737,6 +2936,18 @@ packages: react: '*' react-native: '*' + react-native-svg-transformer@1.5.3: + resolution: {integrity: sha512-M4uFg5pUt35OMgjD4rWWbwd6PmxV96W7r/gQTTa+iZA5B+jO6aURhzAZGLHSrg1Kb91cKG0Rildy9q1WJvYstg==} + peerDependencies: + react-native: '>=0.59.0' + react-native-svg: '>=12.0.0' + + react-native-svg@15.12.1: + resolution: {integrity: sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==} + peerDependencies: + react: '*' + react-native: '*' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -2837,6 +3048,10 @@ packages: resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} engines: {node: '>= 4.0.0'} + resolve-from@4.0.0: + resolution: {integrity: sha1-SrzYUq0y3Xuqv+m0DgCjbbXzkuY=} + engines: {node: '>=4'} + resolve-from@5.0.0: resolution: {integrity: sha1-w1IlhD3493bfIcV1V7wIfp39/Gk=} engines: {node: '>=8'} @@ -2968,6 +3183,9 @@ packages: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3064,6 +3282,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-parser@2.0.4: + resolution: {integrity: sha1-/cLinhOVFzYUC3bLEiyO5mMOtrU=} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} @@ -4773,6 +4999,87 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + + '@svgr/babel-preset@8.1.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.28.6) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.28.6) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.28.6) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.28.6) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.28.6) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.28.6) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.28.6) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.28.6) + + '@svgr/core@8.1.0(typescript@5.9.3)': + dependencies: + '@babel/core': 7.28.6 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.6) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.9.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.28.6 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + dependencies: + '@babel/core': 7.28.6 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.6) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + svgo: 3.3.2 + transitivePeerDependencies: + - typescript + + '@trysound/sax@0.2.0': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -5053,6 +5360,8 @@ snapshots: big-integer@1.6.52: {} + boolbase@1.0.0: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -5116,6 +5425,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.1 + callsites@3.1.0: {} + camelcase@5.3.1: {} camelcase@6.3.0: {} @@ -5238,6 +5549,15 @@ snapshots: dependencies: browserslist: 4.28.1 + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -5256,6 +5576,35 @@ snapshots: dependencies: hyphenate-style-name: 1.1.0 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + csstype@3.2.3: {} debug@2.6.9: @@ -5302,6 +5651,29 @@ snapshots: detect-node-es@1.1.0: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + dotenv-expand@11.0.7: dependencies: dotenv: 16.4.7 @@ -5324,8 +5696,14 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + env-editor@0.4.2: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -5396,6 +5774,12 @@ snapshots: expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react: 19.1.0 + expo-linear-gradient@15.0.8(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) + expo-linking@8.0.11(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: expo-constants: 18.0.13(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) @@ -5738,6 +6122,11 @@ snapshots: dependencies: queue: 6.0.2 + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + imurmurhash@0.1.4: {} inflight@1.0.6: @@ -5762,6 +6151,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-arrayish@0.2.1: {} + is-arrayish@0.3.4: {} is-callable@1.2.7: {} @@ -5909,6 +6300,8 @@ snapshots: jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json5@2.2.3: {} kleur@3.0.3: {} @@ -5995,6 +6388,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + lru-cache@10.4.3: {} lru-cache@11.2.5: {} @@ -6011,6 +6408,12 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.0.14: {} + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} @@ -6253,6 +6656,11 @@ snapshots: nested-error-stacks@2.0.1: {} + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -6272,6 +6680,10 @@ snapshots: semver: 7.7.3 validate-npm-package-name: 5.0.1 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nullthrows@1.1.1: {} ob1@0.83.3: @@ -6352,12 +6764,25 @@ snapshots: p-try@2.2.0: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.28.6 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse-png@2.1.0: dependencies: pngjs: 3.4.0 parseurl@1.3.3: {} + path-dirname@1.0.2: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -6371,6 +6796,8 @@ snapshots: lru-cache: 11.2.5 minipass: 7.1.2 + path-type@4.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6510,6 +6937,26 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 + react-native-svg-transformer@1.5.3(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3): + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) + path-dirname: 1.0.2 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) + react-native-svg: 15.12.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - supports-color + - typescript + + react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + dependencies: + css-select: 5.2.2 + css-tree: 1.1.3 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) + warn-once: 0.1.1 + react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.6 @@ -6661,6 +7108,8 @@ snapshots: rc: 1.2.8 resolve: 1.7.1 + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} resolve-global@1.0.0: @@ -6786,6 +7235,11 @@ snapshots: slugify@1.6.6: {} + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -6868,6 +7322,18 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-parser@2.0.4: {} + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.2.2 + css-tree: 2.3.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + tar@7.5.7: dependencies: '@isaacs/fs-minipass': 4.0.1 diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 86705cf..ec20c6a 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -1,7 +1,10 @@ { "common": { "ok": "OK", - "cancel": "Cancel" + "cancel": "Cancel", + "error": "Error", + "openLinkError": "Cannot open link", + "back": "Back" }, "onboarding": { "title": "Welcome", @@ -33,7 +36,39 @@ "like": "Like", "dislike": "Dislike", "favorites": "Favorites", - "settings": "Settings" + "settings": "Settings", + "theme": "Theme", + "profile": "Me" + }, + "theme": { + "title": "Theme", + "scenery": "Scenery", + "color": "Color" + }, + "profile": { + "title": "Me", + "favorites": "My Likes", + "widget": "Widget", + "dailyReminder": "Daily Reminder", + "privacy": "Privacy Policy", + "terms": "Terms of Use", + "language": "Language", + "todoTitle": "Notice", + "todoDesc": "This feature is a placeholder for this iteration." + }, + "dailyReminder": { + "title": "Daily Reminder", + "timesUnit": "times", + "pushLabel": "Push Reminder", + "ok": "Ok", + "minus": "Decrease", + "plus": "Increase" + }, + "widget": { + "lockScreen": "Lock Screen Widget", + "homeScreen": "Home Screen Widget", + "previewDate": "Thu, Jan 29", + "previewQuote": "I’m proud of who I am, even while becoming who I want to be." }, "favorites": { "title": "Favorites", @@ -45,6 +80,12 @@ "version": "Version", "widgetTitle": "iOS Widget", "widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like." + }, + "consent": { + "title": "You Are Perfect.", + "subtitle": "Everything Will Be Better.", + "agree": "Agree & Continue", + "privacy": "Privacy Policy", + "terms": "Terms of Use" } } - diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index c5162ad..e6d1b81 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -1,7 +1,8 @@ { "common": { "ok": "Aceptar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "back": "Volver" }, "onboarding": { "title": "Bienvenida", @@ -33,7 +34,39 @@ "like": "Me gusta", "dislike": "No me gusta", "favorites": "Favoritos", - "settings": "Ajustes" + "settings": "Ajustes", + "theme": "Tema", + "profile": "Yo" + }, + "theme": { + "title": "Tema", + "scenery": "Paisaje", + "color": "Color" + }, + "profile": { + "title": "Yo", + "favorites": "Mis Me gusta", + "widget": "Widget", + "dailyReminder": "Recordatorio diario", + "privacy": "Política de Privacidad", + "terms": "Términos de Uso", + "language": "Idioma", + "todoTitle": "Aviso", + "todoDesc": "Esta función es un marcador de posición en esta versión." + }, + "dailyReminder": { + "title": "Recordatorio diario", + "timesUnit": "veces", + "pushLabel": "Recordatorio Push", + "ok": "Ok", + "minus": "Disminuir", + "plus": "Aumentar" + }, + "widget": { + "lockScreen": "Widget de pantalla de bloqueo", + "homeScreen": "Widget de pantalla de inicio", + "previewDate": "Jue, 29 ene", + "previewQuote": "Estoy orgullosa de quien soy, incluso mientras me convierto en quien quiero ser." }, "favorites": { "title": "Favoritos", @@ -45,6 +78,11 @@ "version": "Versión", "widgetTitle": "Widget de iOS", "widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Mindfulness” → añade el tamaño." + }, + "consent": { + "agree": "Aceptar y Continuar", + "privacy": "Política de Privacidad", + "terms": "Términos de Uso" } } diff --git a/client/src/i18n/locales/pt.json b/client/src/i18n/locales/pt.json index 86ded42..a09aa32 100644 --- a/client/src/i18n/locales/pt.json +++ b/client/src/i18n/locales/pt.json @@ -1,7 +1,8 @@ { "common": { "ok": "OK", - "cancel": "Cancelar" + "cancel": "Cancelar", + "back": "Voltar" }, "onboarding": { "title": "Bem-vinda", @@ -33,7 +34,39 @@ "like": "Curtir", "dislike": "Não curtir", "favorites": "Favoritos", - "settings": "Configurações" + "settings": "Configurações", + "theme": "Tema", + "profile": "Eu" + }, + "theme": { + "title": "Tema", + "scenery": "Paisagem", + "color": "Cor" + }, + "profile": { + "title": "Eu", + "favorites": "Minhas curtidas", + "widget": "Widget", + "dailyReminder": "Lembrete diário", + "privacy": "Política de Privacidade", + "terms": "Termos de Uso", + "language": "Idioma", + "todoTitle": "Aviso", + "todoDesc": "Este recurso é um placeholder nesta versão." + }, + "dailyReminder": { + "title": "Lembrete diário", + "timesUnit": "vezes", + "pushLabel": "Lembrete Push", + "ok": "Ok", + "minus": "Diminuir", + "plus": "Aumentar" + }, + "widget": { + "lockScreen": "Widget da tela bloqueada", + "homeScreen": "Widget da tela inicial", + "previewDate": "Qui, 29 jan", + "previewQuote": "Tenho orgulho de quem eu sou, mesmo enquanto me torno quem eu quero ser." }, "favorites": { "title": "Favoritos", @@ -45,6 +78,11 @@ "version": "Versão", "widgetTitle": "Widget do iOS", "widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Mindfulness” → adicione o tamanho." + }, + "consent": { + "agree": "Concordar e Continuar", + "privacy": "Política de Privacidade", + "terms": "Termos de Uso" } } diff --git a/client/src/i18n/locales/zh-CN.json b/client/src/i18n/locales/zh-CN.json index 22ef76e..2e9aab6 100644 --- a/client/src/i18n/locales/zh-CN.json +++ b/client/src/i18n/locales/zh-CN.json @@ -1,7 +1,10 @@ { "common": { "ok": "确定", - "cancel": "取消" + "cancel": "取消", + "error": "错误", + "openLinkError": "无法打开链接", + "back": "返回" }, "onboarding": { "title": "欢迎", @@ -33,7 +36,39 @@ "like": "点赞", "dislike": "讨厌", "favorites": "收藏", - "settings": "设置" + "settings": "设置", + "theme": "主题", + "profile": "我的" + }, + "theme": { + "title": "主题", + "scenery": "风景", + "color": "颜色" + }, + "profile": { + "title": "我的", + "favorites": "我的喜欢", + "widget": "小组件", + "dailyReminder": "每日提醒", + "privacy": "隐私政策", + "terms": "使用条款", + "language": "语言", + "todoTitle": "提示", + "todoDesc": "该功能本期先占位,后续迭代补齐。" + }, + "dailyReminder": { + "title": "每日提醒", + "timesUnit": "次", + "pushLabel": "推送提醒", + "ok": "确定", + "minus": "减少次数", + "plus": "增加次数" + }, + "widget": { + "lockScreen": "锁屏小组件", + "homeScreen": "桌面小组件", + "previewDate": "1月29日周四 · 已至腊月十一", + "previewQuote": "我也对现在的自己感到满意,即使我仍在努力成为想成为的人。" }, "favorites": { "title": "收藏夹", @@ -45,6 +80,12 @@ "version": "版本", "widgetTitle": "iOS 小组件", "widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“正念” → 添加你喜欢的尺寸。" + }, + "consent": { + "title": "你本就完美。", + "subtitle": "一切都会变好。", + "agree": "同意并继续", + "privacy": "隐私协议", + "terms": "用户使用协议" } } - diff --git a/client/src/i18n/locales/zh-TW.json b/client/src/i18n/locales/zh-TW.json index dd56939..4e91b8f 100644 --- a/client/src/i18n/locales/zh-TW.json +++ b/client/src/i18n/locales/zh-TW.json @@ -1,7 +1,8 @@ { "common": { "ok": "確定", - "cancel": "取消" + "cancel": "取消", + "back": "返回" }, "onboarding": { "title": "歡迎", @@ -33,7 +34,39 @@ "like": "喜歡", "dislike": "不喜歡", "favorites": "收藏", - "settings": "設定" + "settings": "設定", + "theme": "主題", + "profile": "我的" + }, + "theme": { + "title": "主題", + "scenery": "風景", + "color": "顏色" + }, + "profile": { + "title": "我的", + "favorites": "我的喜歡", + "widget": "小工具", + "dailyReminder": "每日提醒", + "privacy": "隱私政策", + "terms": "使用條款", + "language": "語言", + "todoTitle": "提示", + "todoDesc": "此功能本期先占位,後續迭代補齊。" + }, + "dailyReminder": { + "title": "每日提醒", + "timesUnit": "次", + "pushLabel": "推送提醒", + "ok": "確定", + "minus": "減少次數", + "plus": "增加次數" + }, + "widget": { + "lockScreen": "鎖屏小工具", + "homeScreen": "桌面小工具", + "previewDate": "1月29日週四 · 已至臘月十一", + "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" }, "favorites": { "title": "收藏夾", @@ -45,6 +78,11 @@ "version": "版本", "widgetTitle": "iOS 小工具", "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" + }, + "consent": { + "agree": "同意並繼續", + "privacy": "隱私協議", + "terms": "用戶使用協議" } } diff --git a/client/src/storage/appStorage.ts b/client/src/storage/appStorage.ts index 6b3bb97..3d0f399 100644 --- a/client/src/storage/appStorage.ts +++ b/client/src/storage/appStorage.ts @@ -7,10 +7,24 @@ const KEY_ONBOARDING_COMPLETED = 'onboarding.completed'; const KEY_PUSH_PROMPT_STATE = 'push.promptState'; const KEY_CONTENT_REACTIONS = 'content.reactions'; const KEY_FAVORITES_ITEMS = 'favorites.items'; +const KEY_CONSENT_ACCEPTED = 'consent.accepted'; +const KEY_USER_PROFILE = 'user.profile'; +const KEY_UI_THEME_MODE = 'ui.theme.mode'; +const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type Reaction = 'like' | 'dislike'; export type ReactionsMap = Record; +export type ThemeMode = 'scenery' | 'color'; +export type UserProfile = { + name?: string; + intents?: string[]; +}; +export type DailyReminderSettings = { + timesPerDay: number; + pushEnabled: boolean; +}; + async function getJson(key: string, fallback: T): Promise { const raw = await AsyncStorage.getItem(key); @@ -65,3 +79,50 @@ export async function addFavorite(contentId: string): Promise { await setJson(KEY_FAVORITES_ITEMS, [...list, contentId]); } +export async function getConsentAccepted(): Promise { + const raw = await AsyncStorage.getItem(KEY_CONSENT_ACCEPTED); + return raw === 'true'; +} + +export async function setConsentAccepted(accepted: boolean): Promise { + await AsyncStorage.setItem(KEY_CONSENT_ACCEPTED, accepted ? 'true' : 'false'); +} + +export async function getThemeMode(): Promise { + const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE); + if (raw === 'scenery' || raw === 'color') return raw; + return 'scenery'; +} + +export async function setThemeMode(mode: ThemeMode): Promise { + await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode); +} + +export async function getUserProfile(): Promise { + return await getJson(KEY_USER_PROFILE, {}); +} + +export async function setUserProfile(profile: UserProfile): Promise { + const current = await getUserProfile(); + await setJson(KEY_USER_PROFILE, { ...current, ...profile }); +} + +export async function getDailyReminderSettings(): Promise { + const s = await getJson(KEY_DAILY_REMINDER_SETTINGS, { + timesPerDay: 3, + pushEnabled: false, + }); + const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3; + return { + timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))), + pushEnabled: Boolean(s.pushEnabled), + }; +} + +export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise { + const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay))); + await setJson(KEY_DAILY_REMINDER_SETTINGS, { + timesPerDay, + pushEnabled: Boolean(settings.pushEnabled), + }); +} diff --git a/client/svg.d.ts b/client/svg.d.ts new file mode 100644 index 0000000..447f3a1 --- /dev/null +++ b/client/svg.d.ts @@ -0,0 +1,13 @@ +declare module '*.svg' { + import * as React from 'react'; + import { SvgProps } from 'react-native-svg'; + + const content: React.FC; + export default content; +} + +declare module '*.png' { + const content: number; + export default content; +} + diff --git a/client/tsconfig.json b/client/tsconfig.json index 909e901..1e0ac7c 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "**/*.ts", "**/*.tsx", + "**/*.d.ts", ".expo/types/**/*.ts", "expo-env.d.ts" ]