APP-PUSH和纯色小组件

This commit is contained in:
吕新雨
2026-02-05 01:14:13 +08:00
parent c1c2c6197d
commit 4c03fce720
28 changed files with 258 additions and 156 deletions

View File

@@ -9,9 +9,9 @@
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"splash": { "splash": {
"image": "./assets/images/splash-icon.png", "image": "./assets/images/splashScreen.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#EAD2BA"
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
@@ -23,7 +23,8 @@
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "com.damer.mindfulness"
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",

View File

@@ -28,6 +28,7 @@ import {
} from '@/src/storage/appStorage'; } from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi'; import { fetchRecoFeed } from '@/src/services/recoApi';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import ProfileModal from '@/components/home/ProfileModal'; import ProfileModal from '@/components/home/ProfileModal';
import ThemeModal from '@/components/home/ThemeModal'; import ThemeModal from '@/components/home/ThemeModal';
@@ -78,7 +79,7 @@ type FeedItem = { content_id: string; text: string };
export default function HomeScreen() { export default function HomeScreen() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const isEnglish = i18n.language?.startsWith('en'); const isEnglish = i18n.language?.startsWith('en');
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery'); const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');

View File

@@ -10,6 +10,7 @@ import { SelectionStep } from '@/components/onboarding/SelectionStep';
import { ReminderStep } from '@/components/onboarding/ReminderStep'; import { ReminderStep } from '@/components/onboarding/ReminderStep';
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring'; import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco'; import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoFeed } from '@/src/services/recoApi'; import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi'; import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
import { import {
@@ -30,7 +31,7 @@ type Step =
const STEPS: Step[] = [ const STEPS: Step[] = [
{ id: 'name', type: 'name' }, { id: 'name', type: 'name' },
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] }, { id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'stressed', 'low'] }, { id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] },
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] }, { id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] }, { id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
{ id: 'reminder', type: 'reminder' }, { id: 'reminder', type: 'reminder' },
@@ -74,7 +75,7 @@ export default function OnboardingScreen() {
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页) // Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
try { try {
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const lang = toBackendLocaleFromLanguageTag(i18n.language);
const { items, meta } = await fetchRecoFeed({ const { items, meta } = await fetchRecoFeed({
k: 30, k: 30,
user_profile: { user_profile: {

View File

@@ -88,16 +88,22 @@ export default function SplashScreen() {
{/* 文案内容 */} {/* 文案内容 */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}> <View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
<Text style={styles.titleText}> <Text style={styles.titleText}>
You Are Perfect.{"\n"} {t('consent.title')}
Everything{"\n"} {'\n'}
Will Be Better. {t('consent.subtitle')}
</Text> </Text>
</View> </View>
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}> <SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
{showConsent && ( {showConsent && (
<> <>
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}> <TouchableOpacity
onPress={handleAgree}
activeOpacity={0.8}
style={styles.buttonWrapper}
accessibilityRole="button"
accessibilityLabel={t('consent.agree')}
>
<WelcomeBtn width={87} height={57} /> <WelcomeBtn width={87} height={57} />
</TouchableOpacity> </TouchableOpacity>
@@ -116,6 +122,8 @@ export default function SplashScreen() {
<Text style={styles.linkText}>{t('consent.terms')}</Text> <Text style={styles.linkText}>{t('consent.terms')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.noticeText}>{t('consent.notice')}</Text>
</> </>
)} )}
</SafeAreaView> </SafeAreaView>
@@ -168,6 +176,14 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
noticeText: {
marginTop: 10,
paddingHorizontal: 28,
fontSize: 12,
lineHeight: 16,
textAlign: 'center',
color: 'rgba(119, 47, 0, 0.45)',
},
linkText: { linkText: {
fontSize: 12, fontSize: 12,
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色 color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色

View File

@@ -4,9 +4,9 @@ import { useFonts } from 'expo-font';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { AppState } from 'react-native'; import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
import { useColorScheme } from '@/components/useColorScheme'; import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n'; import { initI18n } from '@/src/i18n';
@@ -41,6 +41,10 @@ export default function RootLayout() {
...FontAwesome.font, ...FontAwesome.font,
}); });
const [i18nReady, setI18nReady] = useState(false); const [i18nReady, setI18nReady] = useState(false);
const [appReady, setAppReady] = useState(false);
const [splashOverlayVisible, setSplashOverlayVisible] = useState(true);
const splashOpacity = useRef(new Animated.Value(1)).current;
const hasHiddenNativeSplashRef = useRef(false);
// Expo Router uses Error Boundaries to catch errors in the navigation tree. // Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => { useEffect(() => {
@@ -68,17 +72,52 @@ export default function RootLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// 字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁 // 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) { if (loaded && i18nReady) setAppReady(true);
SplashScreen.hideAsync();
}
}, [loaded, i18nReady]); }, [loaded, i18nReady]);
const onLayoutRootView = useCallback(() => {
if (!appReady) return;
if (hasHiddenNativeSplashRef.current) return;
hasHiddenNativeSplashRef.current = true;
// 先隐藏原生 splash再把同款覆盖层淡出视觉上实现平滑过渡
void SplashScreen.hideAsync().finally(() => {
Animated.timing(splashOpacity, {
toValue: 0,
duration: 380,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) setSplashOverlayVisible(false);
});
});
}, [appReady, splashOpacity]);
const content = useMemo(() => {
if (!appReady) return null;
return <RootLayoutNav />;
}, [appReady]);
if (!loaded || !i18nReady) { if (!loaded || !i18nReady) {
return null; return null;
} }
return <RootLayoutNav />; return (
<View style={styles.root} onLayout={onLayoutRootView}>
{content}
{splashOverlayVisible && (
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
<View style={styles.splashOverlay}>
<Image
source={require('../assets/images/splashScreen.png')}
style={styles.splashImage}
resizeMode="contain"
/>
</View>
</Animated.View>
)}
</View>
);
} }
function RootLayoutNav() { function RootLayoutNav() {
@@ -118,3 +157,20 @@ function RootLayoutNav() {
</ThemeProvider> </ThemeProvider>
); );
} }
const styles = StyleSheet.create({
root: {
flex: 1,
},
splashOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
// 与 app.json 的 expo.splash.backgroundColor 保持一致
backgroundColor: '#EAD2BA',
},
splashImage: {
width: '80%',
height: '80%',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -162,23 +162,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const duration = 220; const duration = 220;
const easing = Easing.out(Easing.cubic); const easing = Easing.out(Easing.cubic);
// 进入二级页:从右侧滑入;返回:从左侧滑入 // 需求:去掉左右滑动的切页动效,改为纯淡入淡出
const entering = const entering = FadeIn.duration(duration).easing(easing);
navDirection === 'forward' const exiting = FadeOut.duration(duration).easing(easing);
? 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 { return {
entering, entering,
exiting, exiting,
fadeIn: FadeIn.duration(duration).easing(easing),
fadeOut: FadeOut.duration(duration).easing(easing),
}; };
}, [navDirection]); }, [navDirection]);
@@ -196,11 +186,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
exiting={transition.exiting} exiting={transition.exiting}
style={!isRoot ? { flex: 1 } : undefined} style={!isRoot ? { flex: 1 } : undefined}
> >
<Animated.View
entering={transition.fadeIn}
exiting={transition.fadeOut}
style={!isRoot ? { flex: 1 } : undefined}
>
{page === 'root' ? ( {page === 'root' ? (
<RootPage <RootPage
name={currentName} name={currentName}
@@ -222,7 +207,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
) : ( ) : (
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} /> <WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
)} )}
</Animated.View>
</Animated.View> </Animated.View>
</View> </View>
</SheetModal> </SheetModal>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native'; import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
import { useTranslation } from 'react-i18next';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { OnboardingColors } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg'; import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg'; import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
@@ -14,6 +15,7 @@ interface NameInputStepProps {
} }
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) { export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const blinkAnim = useRef(new Animated.Value(1)).current; const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0; const hasInput = value.trim().length > 0;
@@ -46,7 +48,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary } (!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
]} ]}
> >
{hasInput ? value : (isFocused ? "" : "Mama")} {hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
</Text> </Text>
{isFocused && ( {isFocused && (
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}> <Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native'; import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { OnboardingColors } from '@/constants/OnboardingTheme';
interface OnboardingLayoutProps { interface OnboardingLayoutProps {
@@ -21,6 +22,7 @@ export function OnboardingLayout({
onBack, onBack,
showBackButton = false showBackButton = false
}: OnboardingLayoutProps) { }: OnboardingLayoutProps) {
const { t } = useTranslation();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="dark-content" /> <StatusBar barStyle="dark-content" />
@@ -39,7 +41,7 @@ export function OnboardingLayout({
</View> </View>
<TouchableOpacity onPress={onSkip} style={styles.skipButton}> <TouchableOpacity onPress={onSkip} style={styles.skipButton}>
<Text style={styles.skipText}>skip</Text> <Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
<Image <Image
source={require('@/assets/images/icon/skip_icon.png')} source={require('@/assets/images/icon/skip_icon.png')}
style={styles.skipIcon} style={styles.skipIcon}

View File

@@ -208,6 +208,8 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoCrypto (15.0.8): - ExpoCrypto (15.0.8):
- ExpoModulesCore - ExpoModulesCore
- ExpoDevice (8.0.10):
- ExpoModulesCore
- ExpoFileSystem (19.0.21): - ExpoFileSystem (19.0.21):
- ExpoModulesCore - ExpoModulesCore
- ExpoFont (14.0.11): - ExpoFont (14.0.11):
@@ -2250,6 +2252,7 @@ DEPENDENCIES:
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)" - "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_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-asset/ios`)" - "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_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-asset/ios`)"
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)" - "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
- "ExpoDevice (from `../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios`)"
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)" - "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
- "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`)" - "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_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/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_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
@@ -2362,6 +2365,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/.pnpm/expo-asset@12.0.12_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-asset/ios" :path: "../node_modules/.pnpm/expo-asset@12.0.12_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-asset/ios"
ExpoCrypto: ExpoCrypto:
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios" :path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
ExpoDevice:
:path: "../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios"
ExpoFileSystem: ExpoFileSystem:
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios" :path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
ExpoFont: ExpoFont:
@@ -2547,6 +2552,7 @@ SPEC CHECKSUMS:
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533 expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9 ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664 ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29 ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 77; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -11,10 +11,10 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; }; 1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 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 */; };
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; }; A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; }; A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; }; B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; }; EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
@@ -53,7 +53,7 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; }; A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; }; A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; }; A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
@@ -72,7 +72,7 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = { EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
EmotionWidget.swift, EmotionWidget.swift,
@@ -83,18 +83,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = { EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -210,7 +199,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = { EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */, A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
); );
name = "Recovered References"; name = "Recovered References";
sourceTree = "<group>"; sourceTree = "<group>";
@@ -382,6 +371,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
@@ -397,6 +387,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${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}/ExpoLocalization_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
@@ -475,7 +466,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */, A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -712,6 +703,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;

View File

@@ -4,9 +4,9 @@
"color": { "color": {
"components": { "components": {
"alpha": "1.000", "alpha": "1.000",
"blue": "1.00000000000000", "blue": "0.729411764705882",
"green": "1.00000000000000", "green": "0.823529411764706",
"red": "1.00000000000000" "red": "0.917647058823529"
}, },
"color-space": "srgb" "color-space": "srgb"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -22,6 +22,14 @@
<string>CA92.1</string> <string>CA92.1</string>
</array> </array>
</dict> </dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string> <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
@@ -31,14 +39,6 @@
<string>85F4.1</string> <string>85F4.1</string>
</array> </array>
</dict> </dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array> </array>
<key>NSPrivacyCollectedDataTypes</key> <key>NSPrivacyCollectedDataTypes</key>
<array/> <array/>

View File

@@ -42,7 +42,7 @@
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor> </systemColor>
<namedColor name="SplashScreenBackground"> <namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/> <color alpha="1.000" blue="0.729411764705882" green="0.823529411764706" red="0.917647058823529" customColorSpace="sRGB" colorSpace="custom"/>
</namedColor> </namedColor>
</resources> </resources>
</document> </document>

View File

@@ -6,5 +6,6 @@
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型 // - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译 // - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
#import <React/RCTBridge.h> #import <React/RCTBridge.h>
#import <React/RCTBridgeModule.h>
#import <React/RCTBundleURLProvider.h> #import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h> #import <React/RCTLinkingManager.h>

View File

@@ -237,48 +237,22 @@ struct EmotionWidgetView: View {
var entry: EmotionProvider.Entry var entry: EmotionProvider.Entry
@Environment(\.widgetFamily) var family @Environment(\.widgetFamily) var family
private let deepLink = URL(string: "client:///(app)/home") private let deepLink = URL(string: "client:///(app)/home")
private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5
private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B
var body: some View { var body: some View {
ZStack { // //
cardBackground(colors: [ Text(entry.text)
Color(red: 0.06, green: 0.08, blue: 0.12), .font(fontForFamily())
Color(red: 0.14, green: 0.18, blue: 0.28), .foregroundColor(widgetTextColor)
]) .multilineTextAlignment(.leading)
.lineSpacing(lineSpacingForFamily())
// // .lineLimit(lineLimitForFamily())
Text(entry.text) .minimumScaleFactor(0.78)
.font(fontForFamily()) .padding(paddingForFamily())
.foregroundColor(Color.white.opacity(0.92)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.multilineTextAlignment(.leading) .widgetSolidBackground(widgetBackgroundColor)
.lineSpacing(lineSpacingForFamily()) .widgetURL(deepLink)
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
}
.widgetURL(deepLink)
}
// iOS 15
private func cardBackground(colors: [Color]) -> some View {
ZStack {
LinearGradient(
colors: colors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
//
RadialGradient(
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
center: .topTrailing,
startRadius: 10,
endRadius: 180
)
}
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.14), lineWidth: 1)
)
.cornerRadius(18)
} }
private func fontForFamily() -> Font { private func fontForFamily() -> Font {
@@ -330,6 +304,26 @@ struct EmotionWidgetView: View {
} }
} }
private struct WidgetSolidBackgroundModifier: ViewModifier {
let color: Color
func body(content: Content) -> some View {
if #available(iOSApplicationExtension 17.0, *) {
content.containerBackground(for: .widget) { color }
} else {
content
.background(color)
.ignoresSafeArea()
}
}
}
private extension View {
func widgetSolidBackground(_ color: Color) -> some View {
modifier(WidgetSolidBackgroundModifier(color: color))
}
}
@main @main
struct EmotionWidget: Widget { struct EmotionWidget: Widget {
let kind: String = "EmotionWidget" let kind: String = "EmotionWidget"

View File

@@ -33,9 +33,13 @@ function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_sta
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] { function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
if (!raw) return null; if (!raw) return null;
// UI 当前选项:happy/calm/stressed/low // UI 选项:
// - happy/calm/stressed/low历史选项仍保留兼容
// - okay/tired新增选项
if (raw === 'happy') return 'joyful'; if (raw === 'happy') return 'joyful';
if (raw === 'calm') return 'calm'; if (raw === 'calm') return 'calm';
if (raw === 'okay') return 'neutral';
if (raw === 'tired') return 'tired';
if (raw === 'stressed') return 'overwhelmed'; if (raw === 'stressed') return 'overwhelmed';
if (raw === 'low') return 'low'; if (raw === 'low') return 'low';
return null; return null;

View File

@@ -3,6 +3,8 @@ import * as Localization from 'expo-localization';
import i18n from 'i18next'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { isTraditionalChineseLocaleTag } from './locale';
// 用 require 避免 TS 的 json module 配置差异导致无法编译 // 用 require 避免 TS 的 json module 配置差异导致无法编译
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> }; const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> };
@@ -29,13 +31,8 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage { function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
const tag = languageTag.toLowerCase(); const tag = languageTag.toLowerCase();
// 中文当前仅支持繁体中文zh-TW
if (tag.startsWith('zh')) {
return 'zh-TW';
}
// 其他语言:按前缀匹配(当前仅支持英文)
if (tag.startsWith('en')) return 'en'; if (tag.startsWith('en')) return 'en';
if (isTraditionalChineseLocaleTag(tag)) return 'zh-TW';
return DEFAULT_FALLBACK_LANGUAGE; return DEFAULT_FALLBACK_LANGUAGE;
} }

35
client/src/i18n/locale.ts Normal file
View File

@@ -0,0 +1,35 @@
export type BackendLocale = 'en' | 'tc';
/**
* 判断一个 BCP-47 language tag 是否应视为「繁体中文」TC
*
* 规则(面向当前产品约束:只支持 EN/TC默认 EN
* - 仅在语言为中文zh且脚本为 Hant 或地区为 TW/HK/MO 时,判定为 TC
* - 兼容后端/历史写法:明确包含 tc 也视为 TC
* - 其他情况一律视为 EN
*/
export function isTraditionalChineseLocaleTag(languageTag: string): boolean {
const tag = (languageTag || '').trim().toLowerCase();
if (!tag) return false;
// 兼容:有些链路可能直接传 tc
const parts = tag.split(/[-_]/g).filter(Boolean);
if (parts.includes('tc')) return true;
const lang = parts[0];
if (lang !== 'zh') return false;
// 脚本zh-Hant / zh-Hant-TW / zh-Hant-HK ...
if (tag.includes('hant') || parts.includes('hant')) return true;
// 地区zh-TW / zh-HK / zh-MO
if (parts.includes('tw') || parts.includes('hk') || parts.includes('mo')) return true;
// 其他中文(如 zh / zh-CN / zh-Hans不属于 TC → 回退 EN
return false;
}
export function toBackendLocaleFromLanguageTag(languageTag: string | null | undefined): BackendLocale {
return isTraditionalChineseLocaleTag(languageTag ?? '') ? 'tc' : 'en';
}

View File

@@ -25,39 +25,41 @@
}, },
"onboardingSurvey": { "onboardingSurvey": {
"steps": { "steps": {
"name": { "title": "What should I call you?" }, "name": { "title": "What do you want to be called?", "placeholder": "Mama" },
"status": { "status": {
"title": "Your current stage?", "title": "Which stage of motherhood are you in?",
"options": { "options": {
"pregnant": "Pregnant / preparing for motherhood", "pregnant": "Pregnant / Preparing",
"has_kids": "Already have kids", "has_kids": "Parenting",
"no_fill": "Prefer not to say" "no_fill": "Prefer not to say"
} }
}, },
"emotion": { "emotion": {
"title": "How are you feeling right now?", "title": "How are you feeling right now?",
"options": { "options": {
"happy": "Happy / satisfied", "happy": "Joyful",
"calm": "Calm / grounded", "calm": "Calm",
"stressed": "Stressed / overwhelmed", "okay": "Okay",
"low": "Down / low mood" "tired": "Tired",
"stressed": "Overwhelmed",
"low": "Low"
} }
}, },
"influence": { "influence": {
"title": "What has been affecting you lately?", "title": "Whats been influencing how you feel?",
"options": { "options": {
"family": "Family & kids", "family": "Family",
"work": "Work or study", "work": "Work or study",
"relationship": "Intimate relationship", "relationship": "Relationship",
"friends": "Friends & social life", "friends": "Friends",
"health": "Mental & physical health" "health": "Health"
} }
}, },
"support": { "support": {
"title": "What support do you need most?", "title": "What kind of support do you need most right now?",
"options": { "options": {
"emotional": "Emotional support", "emotional": "Emotional support",
"parenting": "Parenting stress", "parenting": "Parenting pressure",
"self_worth": "Self-worth", "self_worth": "Self-worth",
"anxiety": "Anxiety relief", "anxiety": "Anxiety relief",
"balance": "Rest & balance" "balance": "Rest & balance"
@@ -119,6 +121,9 @@
"widget": { "widget": {
"lockScreen": "Lock Screen Widget", "lockScreen": "Lock Screen Widget",
"homeScreen": "Home Screen Widget", "homeScreen": "Home Screen Widget",
"howToTitle": "How to add the widget",
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.",
"previewDate": "Thu, Jan 29", "previewDate": "Thu, Jan 29",
"previewQuote": "Im proud of who I am, even while becoming who I want to be." "previewQuote": "Im proud of who I am, even while becoming who I want to be."
}, },
@@ -136,10 +141,11 @@
}, },
"consent": { "consent": {
"title": "You Are Perfect.", "title": "You Are Perfect.",
"subtitle": "Everything Will Be Better.", "subtitle": "Everything\nWill Be Better.",
"agree": "Agree & Continue", "agree": "Agree & Continue",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
"terms": "Terms of Use" "terms": "Terms of Use",
"notice": "By continuing, you agree to the Privacy Policy and Terms of Use."
}, },
"permissions": { "permissions": {
"notificationsDenied": "Notifications are denied. Please enable them in Settings." "notificationsDenied": "Notifications are denied. Please enable them in Settings."
@@ -180,7 +186,7 @@
}, },
"onboardingSurvey": { "onboardingSurvey": {
"steps": { "steps": {
"name": { "title": "我可以怎麼稱呼你?" }, "name": { "title": "我可以怎麼稱呼你?", "placeholder": "媽媽" },
"status": { "status": {
"title": "媽媽的狀態?", "title": "媽媽的狀態?",
"options": { "options": {
@@ -194,6 +200,8 @@
"options": { "options": {
"happy": "愉悅、滿足", "happy": "愉悅、滿足",
"calm": "平靜、安穩", "calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣", "stressed": "被壓得有點喘不過氣",
"low": "情緒低落" "low": "情緒低落"
} }
@@ -274,6 +282,9 @@
"widget": { "widget": {
"lockScreen": "鎖屏小工具", "lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具", "homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一", "previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
}, },
@@ -290,9 +301,12 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
}, },
"consent": { "consent": {
"title": "你很完美。",
"subtitle": "一切\n都會更好。",
"agree": "同意並繼續", "agree": "同意並繼續",
"privacy": "隱私協議", "privacy": "隱私協議",
"terms": "用戶使用協議" "terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。"
}, },
"permissions": { "permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"

View File

@@ -1,5 +1,6 @@
import { API_BASE_URL } from '@/src/constants/env'; import { API_BASE_URL } from '@/src/constants/env';
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types'; import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoWidget } from '@/src/services/recoApi'; import { fetchRecoWidget } from '@/src/services/recoApi';
import i18n from 'i18next'; import i18n from 'i18next';
import { getUserProfileScoring } from '@/src/storage/appStorage'; import { getUserProfileScoring } from '@/src/storage/appStorage';
@@ -144,7 +145,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
const top = items?.[0]; const top = items?.[0];
if (!top?.text) return; if (!top?.text) return;
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const lang = toBackendLocaleFromLanguageTag(i18n.language);
await setWidgetDailyRecoCache({ await setWidgetDailyRecoCache({
schema_version: 1, schema_version: 1,
saved_at: new Date().toISOString(), saved_at: new Date().toISOString(),

View File

@@ -20,10 +20,15 @@ describe('legalApi.buildAcceptLanguage', () => {
expect(buildAcceptLanguage()).toBe('en'); expect(buildAcceptLanguage()).toBe('en');
}); });
it('任意 zh* 归一为 tc', () => { it('简中/其他中文不支持时回退为 en', () => {
setLang('zh-CN'); setLang('zh-CN');
expect(buildAcceptLanguage()).toBe('tc'); expect(buildAcceptLanguage()).toBe('en');
setLang('zh');
expect(buildAcceptLanguage()).toBe('en');
});
it('繁体中文归一为 tc', () => {
setLang('zh-TW'); setLang('zh-TW');
expect(buildAcceptLanguage()).toBe('tc'); expect(buildAcceptLanguage()).toBe('tc');
}); });

View File

@@ -1,6 +1,7 @@
import i18n from 'i18next'; import i18n from 'i18next';
import { httpJson } from '../utils/http'; import { httpJson } from '../utils/http';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
export type LegalLinks = { export type LegalLinks = {
privacyPolicyUrl: string; privacyPolicyUrl: string;
@@ -9,17 +10,8 @@ export type LegalLinks = {
}; };
export function buildAcceptLanguage(): 'en' | 'tc' { export function buildAcceptLanguage(): 'en' | 'tc' {
const lang = (i18n.language || '').trim(); // 当前多语言仅支持 EN / TC其他语言统一回退到 en
const lower = lang.toLowerCase(); return toBackendLocaleFromLanguageTag(i18n.language);
// 当前多语言仅支持 EN / TC与 reco 链路一致);其他语言统一回退到 en
if (lower.startsWith('zh')) {
return 'tc';
}
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) {
return 'tc';
}
return 'en';
} }
export async function fetchLegalLinks(): Promise<LegalLinks> { export async function fetchLegalLinks(): Promise<LegalLinks> {

View File

@@ -7,6 +7,7 @@ import { httpJson } from '../utils/http';
import { APP_ENV } from '../constants/env'; import { APP_ENV } from '../constants/env';
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage'; import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
import type { UserProfileScoring } from '../storage/appStorage'; import type { UserProfileScoring } from '../storage/appStorage';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
export type PushEnv = 'dev' | 'prod'; export type PushEnv = 'dev' | 'prod';
@@ -44,11 +45,7 @@ export type PushPreferencesResponse = PushPreferencesRequest & {
}; };
export function buildAcceptLanguage(): 'en' | 'tc' { export function buildAcceptLanguage(): 'en' | 'tc' {
const lang = (i18n.language || '').trim(); return toBackendLocaleFromLanguageTag(i18n.language);
const lower = lang.toLowerCase();
if (lower.startsWith('zh')) return 'tc';
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc';
return 'en';
} }
function toPushEnv(appEnv: typeof APP_ENV): PushEnv { function toPushEnv(appEnv: typeof APP_ENV): PushEnv {

View File

@@ -1,6 +1,7 @@
import i18n from 'i18next'; import i18n from 'i18next';
import type { UserProfileV1_2 } from '../features/userProfileScoring'; import type { UserProfileV1_2 } from '../features/userProfileScoring';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
import { httpJson } from '../utils/http'; import { httpJson } from '../utils/http';
export type RecommendedItem = { export type RecommendedItem = {
@@ -27,7 +28,7 @@ export type RecoRequest = {
}; };
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> { export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
const headers: Record<string, string> = { const headers: Record<string, string> = {
// 让后端做 locale 选择(目前后端只区分 en/tc // 让后端做 locale 选择(目前后端只区分 en/tc
@@ -52,7 +53,7 @@ export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult>
} }
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> { export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en'; const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
const headers: Record<string, string> = { const headers: Record<string, string> = {
// 让后端做 locale 选择(目前后端只区分 en/tc // 让后端做 locale 选择(目前后端只区分 en/tc