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

View File

@@ -28,6 +28,7 @@ import {
} from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import ProfileModal from '@/components/home/ProfileModal';
import ThemeModal from '@/components/home/ThemeModal';
@@ -78,7 +79,7 @@ type FeedItem = { content_id: string; text: string };
export default function HomeScreen() {
const { t, i18n } = useTranslation();
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 [index, setIndex] = useState(0);
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 { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
import {
@@ -30,7 +31,7 @@ type Step =
const STEPS: Step[] = [
{ id: 'name', type: 'name' },
{ 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: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
{ id: 'reminder', type: 'reminder' },
@@ -74,7 +75,7 @@ export default function OnboardingScreen() {
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
try {
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
const lang = toBackendLocaleFromLanguageTag(i18n.language);
const { items, meta } = await fetchRecoFeed({
k: 30,
user_profile: {

View File

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

View File

@@ -4,9 +4,9 @@ import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import 'react-native-reanimated';
import { AppState } from 'react-native';
import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
import { useColorScheme } from '@/components/useColorScheme';
import { initI18n } from '@/src/i18n';
@@ -41,6 +41,10 @@ export default function RootLayout() {
...FontAwesome.font,
});
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.
useEffect(() => {
@@ -68,17 +72,52 @@ export default function RootLayout() {
}, []);
useEffect(() => {
// 字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
if (loaded && i18nReady) {
SplashScreen.hideAsync();
}
// 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) setAppReady(true);
}, [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) {
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() {
@@ -118,3 +157,20 @@ function RootLayoutNav() {
</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 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);
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
const entering = FadeIn.duration(duration).easing(easing);
const exiting = FadeOut.duration(duration).easing(easing);
return {
entering,
exiting,
fadeIn: FadeIn.duration(duration).easing(easing),
fadeOut: FadeOut.duration(duration).easing(easing),
};
}, [navDirection]);
@@ -195,11 +185,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
entering={transition.entering}
exiting={transition.exiting}
style={!isRoot ? { flex: 1 } : undefined}
>
<Animated.View
entering={transition.fadeIn}
exiting={transition.fadeOut}
style={!isRoot ? { flex: 1 } : undefined}
>
{page === 'root' ? (
<RootPage
@@ -223,7 +208,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
)}
</Animated.View>
</Animated.View>
</View>
</SheetModal>
);

View File

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

View File

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

View File

@@ -208,6 +208,8 @@ PODS:
- ExpoModulesCore
- ExpoCrypto (15.0.8):
- ExpoModulesCore
- ExpoDevice (8.0.10):
- ExpoModulesCore
- ExpoFileSystem (19.0.21):
- ExpoModulesCore
- 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`)"
- "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`)"
- "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`)"
- "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`)"
@@ -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"
ExpoCrypto:
: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:
: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:
@@ -2547,6 +2552,7 @@ SPEC CHECKSUMS:
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,10 +11,10 @@
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 */; };
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 */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
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 */; };
@@ -53,7 +53,7 @@
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>"; };
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>"; };
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>"; };
@@ -72,7 +72,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
EmotionWidget.swift,
@@ -83,18 +83,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -210,7 +199,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -382,6 +371,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_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}/ExpoLocalization/ExpoLocalization_privacy.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}/ExpoConstants_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}/ExpoLocalization_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
@@ -475,7 +466,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -712,6 +703,7 @@
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = dwarf;

View File

@@ -4,9 +4,9 @@
"color": {
"components": {
"alpha": "1.000",
"blue": "1.00000000000000",
"green": "1.00000000000000",
"red": "1.00000000000000"
"blue": "0.729411764705882",
"green": "0.823529411764706",
"red": "0.917647058823529"
},
"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>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
@@ -31,14 +39,6 @@
<string>85F4.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>

View File

@@ -42,7 +42,7 @@
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<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>
</resources>
</document>

View File

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

View File

@@ -237,50 +237,24 @@ struct EmotionWidgetView: View {
var entry: EmotionProvider.Entry
@Environment(\.widgetFamily) var family
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 {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
// //
Text(entry.text)
.font(fontForFamily())
.foregroundColor(Color.white.opacity(0.92))
.foregroundColor(widgetTextColor)
.multilineTextAlignment(.leading)
.lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetSolidBackground(widgetBackgroundColor)
.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 {
switch family {
case .systemSmall:
@@ -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
struct EmotionWidget: Widget {
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'] {
if (!raw) return null;
// UI 当前选项:happy/calm/stressed/low
// UI 选项:
// - happy/calm/stressed/low历史选项仍保留兼容
// - okay/tired新增选项
if (raw === 'happy') return 'joyful';
if (raw === 'calm') return 'calm';
if (raw === 'okay') return 'neutral';
if (raw === 'tired') return 'tired';
if (raw === 'stressed') return 'overwhelmed';
if (raw === 'low') return 'low';
return null;

View File

@@ -3,6 +3,8 @@ import * as Localization from 'expo-localization';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { isTraditionalChineseLocaleTag } from './locale';
// 用 require 避免 TS 的 json module 配置差异导致无法编译
// 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> };
@@ -29,13 +31,8 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
const tag = languageTag.toLowerCase();
// 中文当前仅支持繁体中文zh-TW
if (tag.startsWith('zh')) {
return 'zh-TW';
}
// 其他语言:按前缀匹配(当前仅支持英文)
if (tag.startsWith('en')) return 'en';
if (isTraditionalChineseLocaleTag(tag)) return 'zh-TW';
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": {
"steps": {
"name": { "title": "What should I call you?" },
"name": { "title": "What do you want to be called?", "placeholder": "Mama" },
"status": {
"title": "Your current stage?",
"title": "Which stage of motherhood are you in?",
"options": {
"pregnant": "Pregnant / preparing for motherhood",
"has_kids": "Already have kids",
"pregnant": "Pregnant / Preparing",
"has_kids": "Parenting",
"no_fill": "Prefer not to say"
}
},
"emotion": {
"title": "How are you feeling right now?",
"options": {
"happy": "Happy / satisfied",
"calm": "Calm / grounded",
"stressed": "Stressed / overwhelmed",
"low": "Down / low mood"
"happy": "Joyful",
"calm": "Calm",
"okay": "Okay",
"tired": "Tired",
"stressed": "Overwhelmed",
"low": "Low"
}
},
"influence": {
"title": "What has been affecting you lately?",
"title": "Whats been influencing how you feel?",
"options": {
"family": "Family & kids",
"family": "Family",
"work": "Work or study",
"relationship": "Intimate relationship",
"friends": "Friends & social life",
"health": "Mental & physical health"
"relationship": "Relationship",
"friends": "Friends",
"health": "Health"
}
},
"support": {
"title": "What support do you need most?",
"title": "What kind of support do you need most right now?",
"options": {
"emotional": "Emotional support",
"parenting": "Parenting stress",
"parenting": "Parenting pressure",
"self_worth": "Self-worth",
"anxiety": "Anxiety relief",
"balance": "Rest & balance"
@@ -119,6 +121,9 @@
"widget": {
"lockScreen": "Lock 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",
"previewQuote": "Im proud of who I am, even while becoming who I want to be."
},
@@ -136,10 +141,11 @@
},
"consent": {
"title": "You Are Perfect.",
"subtitle": "Everything Will Be Better.",
"subtitle": "Everything\nWill Be Better.",
"agree": "Agree & Continue",
"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": {
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
@@ -180,7 +186,7 @@
},
"onboardingSurvey": {
"steps": {
"name": { "title": "我可以怎麼稱呼你?" },
"name": { "title": "我可以怎麼稱呼你?", "placeholder": "媽媽" },
"status": {
"title": "媽媽的狀態?",
"options": {
@@ -194,6 +200,8 @@
"options": {
"happy": "愉悅、滿足",
"calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣",
"low": "情緒低落"
}
@@ -274,6 +282,9 @@
"widget": {
"lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
},
@@ -290,9 +301,12 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
},
"consent": {
"title": "你很完美。",
"subtitle": "一切\n都會更好。",
"agree": "同意並繼續",
"privacy": "隱私協議",
"terms": "用戶使用協議"
"terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。"
},
"permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"

View File

@@ -1,5 +1,6 @@
import { API_BASE_URL } from '@/src/constants/env';
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 i18n from 'i18next';
import { getUserProfileScoring } from '@/src/storage/appStorage';
@@ -144,7 +145,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
const top = items?.[0];
if (!top?.text) return;
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
const lang = toBackendLocaleFromLanguageTag(i18n.language);
await setWidgetDailyRecoCache({
schema_version: 1,
saved_at: new Date().toISOString(),

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { httpJson } from '../utils/http';
import { APP_ENV } from '../constants/env';
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
import type { UserProfileScoring } from '../storage/appStorage';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
export type PushEnv = 'dev' | 'prod';
@@ -44,11 +45,7 @@ export type PushPreferencesResponse = PushPreferencesRequest & {
};
export function buildAcceptLanguage(): 'en' | 'tc' {
const lang = (i18n.language || '').trim();
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';
return toBackendLocaleFromLanguageTag(i18n.language);
}
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {

View File

@@ -1,6 +1,7 @@
import i18n from 'i18next';
import type { UserProfileV1_2 } from '../features/userProfileScoring';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
import { httpJson } from '../utils/http';
export type RecommendedItem = {
@@ -27,7 +28,7 @@ export type RecoRequest = {
};
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> = {
// 让后端做 locale 选择(目前后端只区分 en/tc
@@ -52,7 +53,7 @@ export async function fetchRecoFeed(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> = {
// 让后端做 locale 选择(目前后端只区分 en/tc