14 Commits

Author SHA1 Message Date
雷汀岚
ce018880f4 fix(i18n): 繁中開屏 consent 文案不生效 - 寫死繁中文案、清理腳本、文件
- splash: 繁中 consent 使用元件內 ZH_TW_CONSENT,避免 bundle 快取
- splash: 使用 isTraditionalChineseLocaleTag 判斷繁中
- i18n: 註解與 __DEV__ debug log
- package: clean:cache, start:clean, ios:clean, clean:ios-build
- ALL_COPY: 故障排除與 consent 只改 zh-TW.json 說明
- spec_kit: Splash Consent overflow 記錄排查結論

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:05:17 +08:00
雷汀岚
b4ec17fcac onboarding: 转场动画、名字步取消自动跳页、标题个性化招呼语、Skip/提醒步等文案与交互优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:33:53 +08:00
雷汀岚
aa4e1e9947 onboarding: 选项字体与问题一致、底部间距、提醒页文案与移除底部 Skip
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:11:13 +08:00
66241e5231 Merge pull request 'damer' (#17) from damer into main
Reviewed-on: #17
2026-02-09 06:49:41 +00:00
吕新雨
e980bd4e4d fix:更新容器启动 2026-02-09 14:47:17 +08:00
吕新雨
0b8bbebf6a fix:增加定时推动 2026-02-09 11:52:11 +08:00
吕新雨
1e1e49ea57 fix:同意隐私 2026-02-05 16:33:58 +08:00
吕新雨
8e71503169 fix:修复 2026-02-05 16:06:49 +08:00
吕新雨
2b67a571bb f 2026-02-05 02:02:14 +08:00
吕新雨
8f84f25616 IOS小组件/文案 2026-02-05 01:49:29 +08:00
吕新雨
4c03fce720 APP-PUSH和纯色小组件 2026-02-05 01:14:13 +08:00
吕新雨
c1c2c6197d fix:小组件- PUSH 2026-02-03 17:43:58 +08:00
d742b398ef Merge pull request 'docs: 更新隐私协议与用户使用协议' (#16) from lei into main
Reviewed-on: #16
2026-02-03 05:51:54 +00:00
雷汀岚
3f91e734fa docs: 更新隐私协议与用户使用协议 2026-02-02 18:58:12 +08:00
113 changed files with 9157 additions and 1176 deletions

View File

@@ -1,5 +1,69 @@
请开始完成编码 请开始完成编码
客户端请按照标准的RN架构目录写代码 客户端请按照标准的RN架构目录写代码
客户端API请求 统一使用utlis中封装的请求
客户端的架构目录参考
project-root
├── android/ # Android 原生工程
├── ios/ # iOS 原生工程
├── src/ # 业务代码主目录 ⭐⭐⭐
│ ├── app.tsx # App 入口(注册 Provider / Navigation
│ ├── navigation/ # 路由导航
│ │ ├── index.tsx
│ │ ├── RootNavigator.tsx
│ │ └── types.ts
│ ├── screens/ # 页面Screen 级别)
│ │ ├── Home/
│ │ │ ├── index.tsx
│ │ │ ├── styles.ts
│ │ │ └── hooks.ts
│ │ └── Profile/
│ ├── components/ # 通用 UI 组件(无业务)
│ │ ├── Button/
│ │ │ ├── index.tsx
│ │ │ └── styles.ts
│ │ └── Empty/
│ ├── modules/ # 业务模块(强烈推荐)
│ │ ├── user/
│ │ │ ├── api.ts
│ │ │ ├── model.ts
│ │ │ ├── store.ts
│ │ │ └── index.ts
│ │ └── emotion/
│ ├── services/ # 跨模块服务(网络、存储等)
│ │ ├── http.ts # axios/fetch 封装
│ │ ├── storage.ts # AsyncStorage 封装
│ │ └── logger.ts
│ ├── store/ # 全局状态Redux / Zustand / Jotai
│ │ ├── index.ts
│ │ └── middleware.ts
│ ├── hooks/ # 全局通用 hooks
│ │ ├── useTheme.ts
│ │ └── useDebounce.ts
│ ├── utils/ # 工具函数
│ │ ├── date.ts
│ │ └── uuid.ts
│ ├── constants/ # 常量
│ │ ├── colors.ts
│ │ ├── env.ts
│ │ └── storageKeys.ts
│ ├── assets/ # 静态资源
│ │ ├── images/
│ │ ├── icons/
│ │ └── fonts/
│ ├── theme/ # 主题系统
│ │ ├── index.ts
│ │ └── dark.ts
│ └── types/ # 全局 TS 类型
│ └── index.d.ts
├── __tests__/ # 测试
├── .env # 环境变量
├── babel.config.js
├── metro.config.js
├── tsconfig.json
├── package.json
└── index.js # RN 启动入口
后端请按照标准的python FastAPI 架构目录写代码 后端请按照标准的python FastAPI 架构目录写代码
现在多语言仅支持 EN / TC 现在多语言仅支持 EN / TC
整个task.md执行完毕后需要在对应的overview.md标记并且说明变更的文件名 整个task.md执行完毕后需要在对应的overview.md标记并且说明变更的文件名

View File

@@ -1,3 +1,7 @@
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000 EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
EXPO_PUBLIC_ENV=dev EXPO_PUBLIC_ENV=dev
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
#
# Expo/EAS 项目 IDUUID。用于真机获取 Expo Push Tokenexpo-notifications
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef

1
client/.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com

30
client/app.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { ConfigContext, ExpoConfig } from 'expo/config';
/**
* 运行时获取 Push Tokenexpo-notifications在真机/Dev Client 场景下通常需要 projectId。
*
* 这里把 projectId 注入到 `extra.eas.projectId`
* - 开发/本地:从 `.env.local`EXPO_PUBLIC_EAS_PROJECT_ID读取并写入配置
* - CI/EAS也可通过环境变量注入EXPO_PUBLIC_EAS_PROJECT_ID 或 EAS_PROJECT_ID
*/
export default ({ config }: ConfigContext): ExpoConfig => {
const projectId =
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
// 兼容部分 CI/EAS 注入的变量名
process.env.EAS_PROJECT_ID ||
undefined;
return {
...config,
extra: {
...(config.extra ?? {}),
eas: {
// 保留已有配置,再覆盖 projectId
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(((config.extra as any) ?? {}).eas ?? {}),
projectId: projectId ?? (config.extra as any)?.eas?.projectId,
},
},
};
};

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",
@@ -35,6 +36,13 @@
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
} },
"extra": {
"eas": {
"projectId": "c519f016-e5c8-426c-868f-5545dce8beef"
},
"router": {}
},
"owner": "damersu"
} }
} }

View File

@@ -1,9 +1,22 @@
import { useEffect } from 'react';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
export default function AppLayout() { export default function AppLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => {
// 小组件数据同步(仅 iOS 生效;内部会判断原生模块是否可用)
// 目的:避免“仅 Onboarding 写入一次”导致老用户小组件一直显示兜底文案
void (async () => {
await syncWidgetConfig();
await syncWidgetUserProfileFromStorage();
await ensureDailyWidgetRecoUpToDate({ reason: 'app_start' });
})();
}, []);
return ( return (
<Stack <Stack
screenOptions={{ screenOptions={{
@@ -13,6 +26,8 @@ export default function AppLayout() {
<Stack.Screen <Stack.Screen
name="home" name="home"
options={{ options={{
// Home 页不使用系统 Header避免 iOS 原生导航栏自带的“毛玻璃/液玻璃”材质
headerShown: false,
// 卡片页标题按设计留空(右上角为 icon 按钮) // 卡片页标题按设计留空(右上角为 icon 按钮)
title: '', title: '',
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -1,7 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react'; import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native'; import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, { import Animated, {
Easing, Easing,
runOnJS, runOnJS,
@@ -24,9 +25,13 @@ import {
getRecoFeedHistory, getRecoFeedHistory,
recordRecoFeedServed, recordRecoFeedServed,
type ThemeMode, type ThemeMode,
getSuixinThemeState,
setSuixinThemeState,
type SuixinThemeStateV1,
} 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';
@@ -36,6 +41,9 @@ import MyIcon from '@/assets/images/home/my.svg';
import LikeFilledIcon from '@/assets/images/home/like_filled.svg'; import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
import LikeIcon from '@/assets/images/icon/like_icon.svg'; import LikeIcon from '@/assets/images/icon/like_icon.svg';
import { getBootId } from '@/src/utils/bootSession';
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 预定义风景图列表 // 预定义风景图列表
@@ -77,10 +85,11 @@ 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 navigation = useNavigation(); 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');
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
const [themeOpen, setThemeOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false); const [profileOpen, setProfileOpen] = useState(false);
const [profileName, setProfileName] = useState<string | undefined>(undefined); const [profileName, setProfileName] = useState<string | undefined>(undefined);
@@ -93,12 +102,55 @@ export default function HomeScreen() {
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行 // 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
const feedItemsRef = useRef<FeedItem[]>([]); const feedItemsRef = useRef<FeedItem[]>([]);
const isFetchingRef = useRef(false); const isFetchingRef = useRef(false);
const themeModeRef = useRef<ThemeMode>('scenery');
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
useEffect(() => { useEffect(() => {
feedItemsRef.current = feedItems; feedItemsRef.current = feedItems;
}, [feedItems]); }, [feedItems]);
useEffect(() => { useEffect(() => {
isFetchingRef.current = isFetching; isFetchingRef.current = isFetching;
}, [isFetching]); }, [isFetching]);
useEffect(() => {
themeModeRef.current = themeMode;
}, [themeMode]);
const ensureSuixinReady = useCallback(async () => {
const bootId = getBootId();
const stored = await getSuixinThemeState();
if (stored && stored.boot_id === bootId) {
suixinStateRef.current = stored;
setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]);
return stored;
}
const profile = await getUserProfileScoring();
const next = buildInitialSuixinState({ bootId, profile });
suixinStateRef.current = next;
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
await setSuixinThemeState(next);
return next;
}, []);
const advanceSuixinOnNextContent = useCallback(async () => {
if (themeModeRef.current !== 'suixin') return;
const bootId = getBootId();
let current = suixinStateRef.current;
if (!current) {
current = await getSuixinThemeState();
}
// 冷启动后首次触发/或状态丢失:先初始化
if (!current || current.boot_id !== bootId) {
await ensureSuixinReady();
return;
}
const next = advanceSuixinState(current);
suixinStateRef.current = next;
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
await setSuixinThemeState(next);
}, [ensureSuixinReady]);
// 动画相关 Shared Values // 动画相关 Shared Values
const translateY = useSharedValue(0); const translateY = useSharedValue(0);
@@ -179,6 +231,14 @@ export default function HomeScreen() {
setThemeModeState(mode); setThemeModeState(mode);
setProfileName(profile.name); setProfileName(profile.name);
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
if (mode === 'suixin') {
ensureSuixinReady().catch(() => {
// ignore失败时回退默认中性底色
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
});
}
// 语言切换时:旧语言缓存不复用,触发重新拉取 // 语言切换时:旧语言缓存不复用,触发重新拉取
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) { if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text }))); setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
@@ -192,16 +252,19 @@ export default function HomeScreen() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fetchNewFeed, recoLang]) }, [fetchNewFeed, recoLang, ensureSuixinReady])
); );
const backgroundColor = useMemo(() => { const backgroundColor = useMemo(() => {
if (themeMode === 'suixin') {
return suixinBgColor;
}
if (themeMode === 'color') { if (themeMode === 'color') {
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length; const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
return THEME_COLORS[colorIndex]; return THEME_COLORS[colorIndex];
} }
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示) return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
}, [themeMode, index]); }, [themeMode, suixinBgColor, index]);
// 计算当前应该显示的风景图索引(滑动 10 次切换一张) // 计算当前应该显示的风景图索引(滑动 10 次切换一张)
const natureImageIndex = useMemo(() => { const natureImageIndex = useMemo(() => {
@@ -210,32 +273,6 @@ export default function HomeScreen() {
const currentNatureImage = NATURE_IMAGES[natureImageIndex]; const currentNatureImage = NATURE_IMAGES[natureImageIndex];
useLayoutEffect(() => {
navigation.setOptions({
headerShadowVisible: false,
// 为了让风景/颜色两种主题下“文案的视觉居中位置”一致,统一使用透明 Header
// 颜色主题下 Header 透明也不会影响观感(背景就是纯色)
headerStyle: { backgroundColor: 'transparent' },
headerTransparent: true,
headerRight: () => (
<View style={styles.headerRight}>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
</CircleIconButton>
</View>
),
});
}, [backgroundColor, themeMode, navigation, t]);
const textAnimatedStyle = useAnimatedStyle(() => ({ const textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }], transform: [{ translateY: translateY.value }],
opacity: opacity.value, opacity: opacity.value,
@@ -257,6 +294,7 @@ export default function HomeScreen() {
// 2. 切换数据索引 // 2. 切换数据索引
runOnJS(setIndex)(index + 1); runOnJS(setIndex)(index + 1);
runOnJS(setLikeFilled)(false); runOnJS(setLikeFilled)(false);
runOnJS(advanceSuixinOnNextContent)();
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条) // 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
if (index + 5 >= currentFeed.length && !isFetching) { if (index + 5 >= currentFeed.length && !isFetching) {
@@ -357,6 +395,13 @@ export default function HomeScreen() {
setThemeModeState(next); setThemeModeState(next);
await setThemeMode(next); await setThemeMode(next);
setThemeOpen(false); setThemeOpen(false);
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
if (next === 'suixin') {
await ensureSuixinReady().catch(() => {
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
});
}
} }
return ( return (
@@ -368,6 +413,23 @@ export default function HomeScreen() {
resizeMode="cover" resizeMode="cover"
/> />
)} )}
{/* 自绘顶部按钮:不使用系统 Header彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
<View style={[styles.topRight, { top: insets.top + 8 }]}>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
</CircleIconButton>
</View>
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}> <Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}> <Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
{item.text} {item.text}
@@ -384,12 +446,12 @@ export default function HomeScreen() {
style={styles.reactionInner} style={styles.reactionInner}
> >
{likeFilled ? ( {likeFilled ? (
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} /> <LikeFilledIcon width={35} height={36} color="#EA6969" />
) : ( ) : (
<LikeIcon <LikeIcon
width={35} width={35}
height={36} height={36}
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }} color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
/> />
)} )}
</Pressable> </Pressable>
@@ -435,10 +497,12 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
headerRight: { topRight: {
position: 'absolute',
right: 20,
flexDirection: 'row', flexDirection: 'row',
gap: 10, gap: 10,
paddingRight: 10, zIndex: 30,
}, },
circleBtn: { circleBtn: {
width: 34, width: 34,

View File

@@ -12,7 +12,6 @@ export default function OnboardingLayout() {
}} }}
> >
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} /> <Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
</Stack> </Stack>
); );
} }

View File

@@ -1,13 +1,18 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout'; import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
import { NameInputStep } from '@/components/onboarding/NameInputStep'; import { NameInputStep } from '@/components/onboarding/NameInputStep';
import { SelectionStep } from '@/components/onboarding/SelectionStep'; 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 { 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 { import {
recordRecoFeedServed, recordRecoFeedServed,
setOnboardingCompleted, setOnboardingCompleted,
@@ -15,6 +20,7 @@ import {
setDailyReminderSettings, setDailyReminderSettings,
setUserProfileScoring, setUserProfileScoring,
setRecoFeedCache, setRecoFeedCache,
setPushPromptState,
} from '@/src/storage/appStorage'; } from '@/src/storage/appStorage';
type Step = type Step =
@@ -25,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' },
@@ -50,9 +56,8 @@ export default function OnboardingScreen() {
}, [t, currentStep]); }, [t, currentStep]);
async function onFinish() { async function onFinish() {
// 请求推送权限 // 用户选择每日次数 > 0在此页直接触发系统通知权限已移除单独的 push 引导页)。
const { status } = await Notifications.requestPermissionsAsync(); const wantsPush = reminderTimes > 0;
const pushEnabled = status === 'granted';
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过) // 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections); const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
@@ -61,9 +66,16 @@ export default function OnboardingScreen() {
const scoringProfile = buildUserProfileFromQuestionnaire(answers); const scoringProfile = buildUserProfileFromQuestionnaire(answers);
await setUserProfileScoring(scoringProfile); await setUserProfileScoring(scoringProfile);
// 同步到 App Group供 iOS Widget 拉取与展示
await syncWidgetConfig();
await syncWidgetUserProfileFromScoring(scoringProfile);
// 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞)
await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile });
// 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: {
@@ -96,10 +108,57 @@ export default function OnboardingScreen() {
}); });
await setDailyReminderSettings({ await setDailyReminderSettings({
timesPerDay: reminderTimes, timesPerDay: reminderTimes,
pushEnabled: pushEnabled // 这里表示“用户意愿”,不代表系统权限一定已 granted
pushEnabled: wantsPush,
}); });
await setOnboardingCompleted(true); await setOnboardingCompleted(true);
// 用户选择 0 次(关闭)或跳过:直接进入首页
if (!wantsPush) {
await setPushPromptState('skipped');
router.replace('/(app)/home'); router.replace('/(app)/home');
return;
}
// 用户想要 Push请求系统权限并尽量完成 token/偏好上报(失败不阻塞进入首页)
await setPushPromptState('unknown');
try {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
await setPushPromptState('skipped');
return;
}
// iOS 模拟器通常无法获取 Expo Push Token系统限制此时不要提示“失败”而是明确告知需要真机测试。
if (Device.osName === 'iOS' && !Device.isDevice) {
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
await setPushPromptState('unknown');
return;
}
// 1) 获取 Expo Push Token失败才认为“推送开启失败”
const expoPushToken = await getExpoPushTokenOrThrow();
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await registerPushToken({ pushToken: expoPushToken });
// 3) 上报推送偏好(幂等)
// 注意:这一步失败时,后端仍可能已成功接收 token。
// 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
try {
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败Onboarding不阻塞', msg);
}
await setPushPromptState('enabled');
} catch (e) {
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
await setPushPromptState('unknown');
} finally {
router.replace('/(app)/home');
}
} }
const onNext = () => { const onNext = () => {
@@ -116,14 +175,17 @@ export default function OnboardingScreen() {
} }
}; };
const onSkip = async () => { /** 只跳過當前這一步(不填/不選當前題,進入下一步) */
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用 const handleSkipCurrentStep = () => {
const scoringProfile = buildUserProfileFromQuestionnaire({}); if (currentStep.type === 'name') {
await setUserProfileScoring(scoringProfile); onNext();
} else if (currentStep.type === 'selection') {
// 标记已完成,避免下次启动再次进入 Onboarding setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
await setOnboardingCompleted(true); onNext();
router.replace('/(app)/home'); } else if (currentStep.type === 'reminder') {
setReminderTimes(0);
onFinish();
}
}; };
// 题目为多选:点击切换选中状态 // 题目为多选:点击切换选中状态
@@ -147,9 +209,10 @@ export default function OnboardingScreen() {
title={currentTitle} title={currentTitle}
currentStep={stepIndex} currentStep={stepIndex}
totalSteps={STEPS.length - 1} totalSteps={STEPS.length - 1}
onSkip={onSkip} onSkip={handleSkipCurrentStep}
onBack={onBack} onBack={onBack}
showBackButton={stepIndex > 0} showBackButton={stepIndex > 0}
userName={name}
> >
{currentStep.type === 'name' && ( {currentStep.type === 'name' && (
<NameInputStep <NameInputStep
@@ -170,9 +233,14 @@ export default function OnboardingScreen() {
{currentStep.type === 'reminder' && ( {currentStep.type === 'reminder' && (
<ReminderStep <ReminderStep
value={reminderTimes} value={Math.max(1, reminderTimes)}
onChange={setReminderTimes} onChange={setReminderTimes}
onFinish={onFinish} onFinish={onFinish}
onSkip={() => {
// 跳过每日提醒:视为 0 次(关闭)
setReminderTimes(0);
onFinish();
}}
/> />
)} )}
</OnboardingLayout> </OnboardingLayout>

View File

@@ -1,74 +0,0 @@
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import * as Notifications from 'expo-notifications';
import { setPushPromptState } from '@/src/storage/appStorage';
export default function PushPromptScreen() {
const router = useRouter();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
async function goHome() {
router.replace('/(app)/home');
}
async function onLater() {
await setPushPromptState('skipped');
await goHome();
}
async function onEnableNow() {
// 触发系统权限申请(可失败,但不阻塞进入主功能)
setLoading(true);
try {
await Notifications.requestPermissionsAsync();
await setPushPromptState('enabled');
await goHome();
} catch (e) {
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
await goHome();
} finally {
setLoading(false);
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{t('push.cardTitle')}</Text>
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
</View>
<View style={styles.actions}>
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
</Pressable>
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
card: {
borderRadius: 18,
padding: 20,
backgroundColor: '#111827',
gap: 10
},
title: { color: 'white', fontSize: 20, fontWeight: '700' },
desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 },
actions: { flexDirection: 'row', gap: 12 },
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
primary: { backgroundColor: '#16A34A' },
secondary: { backgroundColor: '#F3F4F6' },
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
secondaryText: { color: '#111827' }
});

View File

@@ -1,10 +1,14 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import { useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage'; import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getOnboardingCompleted } from '@/src/storage/appStorage';
import { API_BASE_URL } from '@/src/constants/env';
import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
// 导入 SVG 组件 // 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg'; import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
@@ -12,20 +16,55 @@ import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
const ZH_TW_CONSENT = {
title: '我們知道,',
subtitle: '當媽媽很不容易。',
subtitleSecondary: '這裡給你一些溫柔的肯定與提醒',
};
export default function SplashScreen() { export default function SplashScreen() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [showConsent, setShowConsent] = useState(false); const [showConsent, setShowConsent] = useState(false);
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW其餘用 i18n
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
const title = isZhTW ? ZH_TW_CONSENT.title : t('consent.title');
const subtitle = isZhTW ? ZH_TW_CONSENT.subtitle : t('consent.subtitle');
const subtitleSecondary = isZhTW ? ZH_TW_CONSENT.subtitleSecondary : t('consent.subtitleSecondary');
useEffect(() => {
if (typeof __DEV__ !== 'undefined' && __DEV__ && showConsent) {
console.log('[i18n consent] language=', i18n.language, 'title=', title, 'subtitle=', subtitle);
}
}, [showConsent, i18n.language, title, subtitle]);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
const [linksLoading, setLinksLoading] = useState(false);
const mountedRef = useRef(true);
useEffect(() => { useEffect(() => {
checkConsent(); checkConsent();
}, []); }, []);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const checkConsent = async () => { const checkConsent = async () => {
const accepted = await getConsentAccepted(); const accepted = await getConsentAccepted();
setShowConsent(!accepted); setShowConsent(!accepted);
if (accepted) { if (accepted) {
router.replace('/'); // 已同意协议则直接分发到目标页,避免先回到 /index再二次跳转导致“闪一下”
const completed = await getOnboardingCompleted();
if (completed) {
router.replace('/(app)/home');
} else {
router.replace('/(onboarding)/onboarding');
}
} }
}; };
@@ -44,6 +83,50 @@ export default function SplashScreen() {
} }
}; };
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
if (mountedRef.current) setLinksLoading(true);
try {
const res = await fetchLegalLinks();
const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
if (mountedRef.current) setLinks(next);
return next;
} catch (e) {
// 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
if (__DEV__) console.log('[LegalLinks] 拉取失败splash:', e);
if (mountedRef.current) setLinks({});
return {};
} finally {
if (mountedRef.current) setLinksLoading(false);
}
}
async function handleOpenLegal(type: 'privacy' | 'terms') {
const currentUrl = type === 'privacy' ? links.privacy : links.terms;
if (currentUrl) {
await openLink(currentUrl);
return;
}
// 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应”
const next = await refreshLegalLinks();
const nextUrl = type === 'privacy' ? next.privacy : next.terms;
if (nextUrl) {
await openLink(nextUrl);
return;
}
const msg =
typeof __DEV__ !== 'undefined' && __DEV__
? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL })
: t('consent.linkUnavailable');
Alert.alert(t('common.notice'), msg);
}
// 拉取协议链接(由后端按语言下发;默认 EN
useEffect(() => {
void refreshLegalLinks();
}, []);
const bgDecorationTop = 363; const bgDecorationTop = 363;
const bgDecorationHeight = height * 0.6; const bgDecorationHeight = height * 0.6;
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25); const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
@@ -64,31 +147,58 @@ export default function SplashScreen() {
/> />
</View> </View>
{/* 文案内容 */} {/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
<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"} {title}
Everything{"\n"} {'\n'}
Will Be Better. {subtitle}
</Text> </Text>
{subtitleSecondary ? (
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
) : null}
</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>
<View style={styles.linksContainer}> <Text style={styles.noticeText}>
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}> <Trans
<Text style={styles.linkText}>{t('consent.privacy')}</Text> i18nKey="consent.noticeRich"
</TouchableOpacity> values={{
<View style={styles.divider} /> privacyLabel: t('consent.privacy'),
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}> termsLabel: t('consent.terms'),
<Text style={styles.linkText}>{t('consent.terms')}</Text> privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
</TouchableOpacity> termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
</View> }}
components={{
privacy: (
<Text
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('privacy')}
suppressHighlighting
/>
),
terms: (
<Text
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('terms')}
suppressHighlighting
/>
),
}}
/>
</Text>
</> </>
)} )}
</SafeAreaView> </SafeAreaView>
@@ -127,6 +237,14 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif', fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
}, },
consentSubtitleSecondary: {
marginTop: 12,
fontSize: 16,
lineHeight: 22,
color: 'rgba(119, 47, 0, 0.6)',
textAlign: 'center',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
bottomContainer: { bottomContainer: {
position: 'absolute', position: 'absolute',
bottom: 60, bottom: 60,
@@ -137,19 +255,22 @@ const styles = StyleSheet.create({
buttonWrapper: { buttonWrapper: {
marginBottom: 40, marginBottom: 40,
}, },
linksContainer: { noticeText: {
flexDirection: 'row', marginTop: 10,
alignItems: 'center', paddingHorizontal: 28,
},
linkText: {
fontSize: 12, fontSize: 12,
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色 lineHeight: 16,
textDecorationLine: 'underline', textAlign: 'center',
color: 'rgba(119, 47, 0, 0.45)',
}, },
divider: { noticeLinkText: {
width: 1, fontSize: 12,
height: 12, // 颜色区分:协议链接更醒目
backgroundColor: 'rgba(119, 47, 0, 0.2)', color: 'rgba(119, 47, 0, 0.75)',
marginHorizontal: 15, textDecorationLine: 'underline',
fontWeight: '600',
},
noticeLinkTextDisabled: {
opacity: 0.55,
}, },
}); });

View File

@@ -4,16 +4,22 @@ 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 { 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';
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常) // 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowAlert: true, shouldShowAlert: true,
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false, shouldPlaySound: false,
shouldSetBadge: false, shouldSetBadge: false,
}), }),
@@ -26,7 +32,8 @@ export {
export const unstable_settings = { export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present. // Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: 'index', // 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下”
initialRouteName: '(splash)/splash',
}; };
// Prevent the splash screen from auto-hiding before asset loading is complete. // Prevent the splash screen from auto-hiding before asset loading is complete.
@@ -38,6 +45,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(() => {
@@ -54,25 +65,89 @@ export default function RootLayout() {
}, []); }, []);
useEffect(() => { useEffect(() => {
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁 // 尽早生成 client_user_id便于后续任意时刻与后端建立关联Push Token/偏好等)
if (loaded && i18nReady) { getOrCreateClientUserId()
SplashScreen.hideAsync(); .then((id) => {
} if (__DEV__) console.log('[client_user_id]', id);
})
.catch((e) => {
console.warn('[client_user_id] 生成失败(不阻塞启动)', e);
});
}, []);
useEffect(() => {
// 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) setAppReady(true);
}, [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() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
syncWidgetConfig().catch(() => {});
syncWidgetUserProfileFromStorage().catch(() => {});
ensureDailyWidgetRecoUpToDate({ reason: 'app_start' }).catch(() => {});
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
// App 回到前台时尝试刷新(失败不阻塞)
ensureDailyWidgetRecoUpToDate({ reason: 'app_active' }).catch(() => {});
}
});
return () => sub.remove();
}, []);
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
{/* 协议页分组(首次启动优先进入) */}
<Stack.Screen name="(splash)" />
{/* 启动分发页:根据 onboarding 状态跳转 */} {/* 启动分发页:根据 onboarding 状态跳转 */}
<Stack.Screen name="index" /> <Stack.Screen name="index" />
@@ -89,3 +164,21 @@ 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: {
// 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉
width: '100%',
height: '100%',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
}, [visible]); }, [visible]);
function clamp(next: number) { function clamp(next: number) {
return Math.min(10, Math.max(1, next)); // 需求050 表示关闭)
return Math.min(5, Math.max(0, next));
} }
async function onOk() { async function onOk() {
if (loading) return; if (loading) return;
setLoading(true); setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled }; const next: DailyReminderSettings = {
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
pushEnabled: Boolean(pushEnabled) && timesPerDay > 0,
};
await setDailyReminderSettings(next); await setDailyReminderSettings(next);
setLoading(false); setLoading(false);
onClose(); onClose();

View File

@@ -3,6 +3,7 @@ import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Di
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Switch } from 'react-native'; import { Switch } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import Animated, { import Animated, {
Easing, Easing,
FadeIn, FadeIn,
@@ -39,6 +40,8 @@ import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg'; import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { changeLanguage } from '@/src/i18n'; import { changeLanguage } from '@/src/i18n';
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@@ -80,6 +83,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
const [page, setPage] = useState<Page>('root'); const [page, setPage] = useState<Page>('root');
const [navDirection, setNavDirection] = useState<NavDirection>('forward'); const [navDirection, setNavDirection] = useState<NavDirection>('forward');
const [currentName, setCurrentName] = useState(propName); const [currentName, setCurrentName] = useState(propName);
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
const isRoot = page === 'root'; const isRoot = page === 'root';
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步 // 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
@@ -90,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
setCurrentName(profile.name); setCurrentName(profile.name);
} }
}); });
// 打开弹窗时拉取协议链接(由后端按语言下发;默认 EN
fetchLegalLinks()
.then((res) => {
setLegalLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
})
.catch((e) => {
if (__DEV__) console.log('[LegalLinks] 拉取失败ProfileModal:', e);
setLegalLinks({});
});
} else { } else {
setNavDirection('back'); setNavDirection('back');
setPage('root'); setPage('root');
@@ -123,6 +137,21 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
handleClose(); handleClose();
} }
const openLink = useCallback(
async (url?: string) => {
if (!url) {
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
return;
}
try {
await WebBrowser.openBrowserAsync(url);
} catch (error) {
Alert.alert(t('common.error'), t('common.openLinkError'));
}
},
[t],
);
const title = useMemo(() => { const title = useMemo(() => {
if (page === 'favorites') return t('profile.favorites'); if (page === 'favorites') return t('profile.favorites');
if (page === 'dailyReminder') return t('dailyReminder.title'); if (page === 'dailyReminder') return t('dailyReminder.title');
@@ -136,23 +165,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]);
@@ -169,11 +188,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
entering={transition.entering} entering={transition.entering}
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
@@ -182,6 +196,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
onOpenWidget={() => go('widget', 'forward')} onOpenWidget={() => go('widget', 'forward')}
onOpenDailyReminder={() => go('dailyReminder', 'forward')} onOpenDailyReminder={() => go('dailyReminder', 'forward')}
onOpenLanguage={() => go('language', 'forward')} onOpenLanguage={() => go('language', 'forward')}
onOpenPrivacy={() => openLink(legalLinks.privacy)}
onOpenTerms={() => openLink(legalLinks.terms)}
/> />
) : page === 'favorites' ? ( ) : page === 'favorites' ? (
<FavoritesPage visible={visible} page={page} /> <FavoritesPage visible={visible} page={page} />
@@ -195,7 +211,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>
); );
@@ -211,12 +226,16 @@ function RootPage({
onOpenWidget, onOpenWidget,
onOpenDailyReminder, onOpenDailyReminder,
onOpenLanguage, onOpenLanguage,
onOpenPrivacy,
onOpenTerms,
}: { }: {
name?: string; name?: string;
onOpenFavorites: () => void; onOpenFavorites: () => void;
onOpenWidget: () => void; onOpenWidget: () => void;
onOpenDailyReminder: () => void; onOpenDailyReminder: () => void;
onOpenLanguage: () => void; onOpenLanguage: () => void;
onOpenPrivacy: () => void;
onOpenTerms: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -244,12 +263,12 @@ function RootPage({
<ListItem <ListItem
icon={<PrivacyIcon width={22} height={22} />} icon={<PrivacyIcon width={22} height={22} />}
title={t('profile.privacy')} title={t('profile.privacy')}
onPress={() => toastTodo(t)} onPress={onOpenPrivacy}
/> />
<ListItem <ListItem
icon={<TermsIcon width={22} height={22} />} icon={<TermsIcon width={22} height={22} />}
title={t('profile.terms')} title={t('profile.terms')}
onPress={() => toastTodo(t)} onPress={onOpenTerms}
/> />
<ListItem <ListItem
icon={<LanguageIcon width={22} height={22} />} icon={<LanguageIcon width={22} height={22} />}
@@ -374,12 +393,14 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
// 1. 获取本地存储设置 // 1. 获取本地存储设置
const s = await getDailyReminderSettings(); const s = await getDailyReminderSettings();
// 【测试模式】:强制模拟无权限状态 // 2. 获取系统通知权限(用于 UI 展示/引导)
const granted = false; const settings = await Notifications.getPermissionsAsync();
const granted = settings.status === 'granted';
if (cancelled) return; if (cancelled) return;
setTimesPerDay(s.timesPerDay); setTimesPerDay(s.timesPerDay);
setPushEnabled(granted); // pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
setPushEnabled(Boolean(s.pushEnabled) && granted);
setHasSystemPermission(granted); setHasSystemPermission(granted);
setLoading(false); setLoading(false);
})(); })();
@@ -412,6 +433,23 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
if (status === 'granted') { if (status === 'granted') {
setPushEnabled(true); setPushEnabled(true);
setHasSystemPermission(true); setHasSystemPermission(true);
// 获取 token 并上报后端(幂等)
try {
const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken });
// 偏好同步失败不应被用户感知为“开启失败”
// (常见现象:后端已接收 token但偏好接口短暂失败/超时)
try {
await setPushPreferences({ enabled: true, timesPerDay });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败ProfileModal不阻塞', msg);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Alert.alert(t('common.notice'), msg);
}
} else { } else {
setPushEnabled(false); setPushEnabled(false);
setHasSystemPermission(false); setHasSystemPermission(false);
@@ -419,18 +457,35 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
} }
} else { } else {
setPushEnabled(false); setPushEnabled(false);
// 关闭时尝试同步到后端(不阻塞)
try {
await setPushPreferences({ enabled: false, timesPerDay: 0 });
} catch {
// ignore
}
} }
}; };
function clamp(next: number) { function clamp(next: number) {
return Math.min(10, Math.max(1, next)); // 需求050 表示关闭)
return Math.min(5, Math.max(0, next));
} }
async function onOk() { async function onOk() {
if (loading) return; if (loading) return;
setLoading(true); setLoading(true);
const next: DailyReminderSettings = { timesPerDay, pushEnabled }; const nextTimes = Math.min(5, Math.max(0, Math.round(timesPerDay)));
const nextEnabled = Boolean(pushEnabled) && nextTimes > 0;
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
await setDailyReminderSettings(next); await setDailyReminderSettings(next);
// 同步后端偏好(幂等;失败不阻塞)
try {
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[PushPreferences] 同步失败', msg);
}
setLoading(false); setLoading(false);
onDone(); onDone();
} }
@@ -464,7 +519,6 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
</Pressable> </Pressable>
</View> </View>
{!hasSystemPermission && (
<View style={styles.remindRow}> <View style={styles.remindRow}>
<View style={styles.rowLeft}> <View style={styles.rowLeft}>
<View style={styles.rowIcon}> <View style={styles.rowIcon}>
@@ -481,7 +535,6 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
/> />
</View> </View>
</View> </View>
)}
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}> <Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
<LinearGradient <LinearGradient
@@ -537,7 +590,8 @@ function WidgetHowToPage() {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [isManual, setIsManual] = useState(false); const [isManual, setIsManual] = useState(false);
const timerRef = useRef<NodeJS.Timeout | null>(null); // React Native 环境下 setInterval 返回值类型与 Node 不同,这里用 ReturnType 兼容
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const images = currentLang === 'en' ? [ const images = currentLang === 'en' ? [
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') }, { id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal'; import SheetModal from '@/components/ui/SheetModal';
export type ThemeMode = 'scenery' | 'color'; import type { ThemeMode } from '@/src/storage/appStorage';
type Props = { type Props = {
visible: boolean; visible: boolean;
@@ -16,7 +16,7 @@ type Props = {
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) { export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}> <SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
<View style={styles.row}> <View style={styles.row}>
<ThemeCard <ThemeCard
title={t('theme.scenery')} title={t('theme.scenery')}
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
style={styles.previewImage} style={styles.previewImage}
/> />
</ThemeCard> </ThemeCard>
<ThemeCard
title={t('theme.suixin')}
selected={mode === 'suixin'}
onPress={() => onSelect('suixin')}
>
<Image
// 占位:一期复用纯色预览图,后续可替换为专用资源
source={require('../../assets/images/theme/theme_color.png')}
resizeMode="cover"
style={styles.previewImage}
/>
</ThemeCard>
</View> </View>
</SheetModal> </SheetModal>
); );
@@ -68,7 +81,12 @@ function ThemeCard({
{children} {children}
{/* 文案展示在图片中心 */} {/* 文案展示在图片中心 */}
<View style={styles.textOverlay}> <View style={styles.textOverlay}>
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}> <Text
style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}
numberOfLines={1}
adjustsFontSizeToFit
minimumFontScale={0.85}
>
{title} {title}
</Text> </Text>
</View> </View>
@@ -81,19 +99,21 @@ function ThemeCard({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
row: { row: {
flexDirection: 'row', flexDirection: 'row',
gap: 30, flexWrap: 'nowrap',
paddingHorizontal: 10, gap: 12,
paddingHorizontal: 4,
paddingBottom: 50, paddingBottom: 50,
paddingTop: 20, paddingTop: 20,
justifyContent: 'center', justifyContent: 'space-between',
}, },
cardContainer: { cardContainer: {
alignItems: 'center', flex: 1,
width: 143, minWidth: 0,
alignItems: 'stretch',
}, },
previewWrapper: { previewWrapper: {
width: 138, width: '100%',
height: 203, aspectRatio: 110 / 178,
borderRadius: 26, borderRadius: 26,
padding: 6.5, padding: 6.5,
justifyContent: 'center', justifyContent: 'center',

View File

@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native'; import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SerifText } from './SerifText'; import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import { OnboardingColors } from '@/constants/OnboardingTheme';
export const INTENTS = [ export const INTENTS = [
{ id: 'love', labelKey: 'intent.love', icon: '❤️' }, { id: 'love', labelKey: 'intent.love', icon: '❤️' },
@@ -20,7 +19,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View style={styles.container}> <View style={styles.container}>
<SerifText style={styles.title}>{t('intent.title')}</SerifText> <Text style={styles.title}>{t('intent.title')}</Text>
<View style={styles.grid}> <View style={styles.grid}>
{INTENTS.map((intent) => { {INTENTS.map((intent) => {
@@ -35,10 +34,10 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
onPress={() => onToggle(intent.id)} onPress={() => onToggle(intent.id)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<SerifText style={styles.icon}>{intent.icon}</SerifText> <Text style={styles.icon}>{intent.icon}</Text>
<SerifText style={[styles.label, isSelected && styles.labelSelected]}> <Text style={[styles.label, isSelected && styles.labelSelected]}>
{t(intent.labelKey)} {t(intent.labelKey)}
</SerifText> </Text>
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
@@ -56,6 +55,8 @@ const styles = StyleSheet.create({
fontSize: 24, fontSize: 24,
marginBottom: 40, marginBottom: 40,
textAlign: 'center', textAlign: 'center',
fontFamily: OnboardingFont.question,
color: OnboardingColors.textPrimary,
}, },
grid: { grid: {
flexDirection: 'row', flexDirection: 'row',
@@ -86,10 +87,12 @@ const styles = StyleSheet.create({
}, },
icon: { icon: {
fontSize: 32, fontSize: 32,
fontFamily: OnboardingFont.question,
}, },
label: { label: {
fontSize: 18, fontSize: 18,
color: OnboardingColors.textPrimary, color: OnboardingColors.textPrimary,
fontFamily: OnboardingFont.question,
}, },
labelSelected: { labelSelected: {
fontWeight: 'bold', fontWeight: 'bold',

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native'; import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
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';
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg'; import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
const { height } = Dimensions.get('window');
interface NameInputStepProps { interface NameInputStepProps {
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
@@ -14,10 +14,30 @@ interface NameInputStepProps {
} }
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) { export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
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;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const subShow = Keyboard.addListener(showEvent, (e) => {
setKeyboardHeight(e.endCoordinates?.height ?? 0);
});
const subHide = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
});
return () => {
subShow.remove();
subHide.remove();
};
}, []);
useEffect(() => { useEffect(() => {
const animation = Animated.loop( const animation = Animated.loop(
Animated.sequence([ Animated.sequence([
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
return () => animation.stop(); return () => animation.stop();
}, [blinkAnim, isFocused]); }, [blinkAnim, isFocused]);
const footerBottom = useMemo(() => {
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
return 16 + insets.bottom + keyboardOffset;
}, [insets.bottom, keyboardHeight]);
return ( return (
<View style={styles.container}> <Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.inputCard}> <View style={styles.inputCard}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
{/* 显示层:文案 + 跟随的光标 */} {/* 显示层:文案 + 跟随的光标 */}
@@ -46,7 +72,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 }]}>
@@ -65,20 +91,29 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
caretHidden={true} caretHidden={true}
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
returnKeyType="done"
blurOnSubmit={true}
onSubmitEditing={() => {
Keyboard.dismiss();
// 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️
}}
/> />
</View> </View>
</View> </View>
<View style={styles.footer}> <View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity <TouchableOpacity
onPress={onNext} onPress={() => {
Keyboard.dismiss();
onNext();
}}
disabled={!hasInput} disabled={!hasInput}
activeOpacity={0.8} activeOpacity={0.8}
> >
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />} {hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </Pressable>
); );
} }
@@ -130,7 +165,6 @@ const styles = StyleSheet.create({
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: height * 0.12,
alignItems: 'center', alignItems: 'center',
} }
}); });

View File

@@ -1,6 +1,10 @@
import React from 'react'; import React, { useRef, useEffect } from 'react';
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native'; import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform, Animated, Easing } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { useTranslation } from 'react-i18next';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
const TRANSITION_OFFSET = 24;
const TRANSITION_DURATION = 280;
interface OnboardingLayoutProps { interface OnboardingLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -10,6 +14,8 @@ interface OnboardingLayoutProps {
onSkip: () => void; onSkip: () => void;
onBack?: () => void; onBack?: () => void;
showBackButton?: boolean; showBackButton?: boolean;
/** 用户名字仅在名字步骤之后的第一个问题currentStep === 1且非空时显示招呼语 */
userName?: string;
} }
export function OnboardingLayout({ export function OnboardingLayout({
@@ -19,8 +25,48 @@ export function OnboardingLayout({
totalSteps, totalSteps,
onSkip, onSkip,
onBack, onBack,
showBackButton = false showBackButton = false,
userName = '',
}: OnboardingLayoutProps) { }: OnboardingLayoutProps) {
const { t } = useTranslation();
const showGreeting = currentStep === 1 && userName.trim().length > 0;
const displayName = userName.trim();
const prevStepRef = useRef(currentStep);
const isFirstRenderRef = useRef(true);
const translateX = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
prevStepRef.current = currentStep;
return;
}
if (prevStepRef.current === currentStep) return;
const direction = currentStep > prevStepRef.current ? 'forward' : 'back';
prevStepRef.current = currentStep;
const startX = direction === 'forward' ? TRANSITION_OFFSET : -TRANSITION_OFFSET;
translateX.setValue(startX);
opacity.setValue(0.72);
Animated.parallel([
Animated.timing(translateX, {
toValue: 0,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.timing(opacity, {
toValue: 1,
duration: TRANSITION_DURATION,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}, [currentStep, translateX, opacity]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="dark-content" /> <StatusBar barStyle="dark-content" />
@@ -39,7 +85,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}
@@ -47,16 +93,29 @@ export function OnboardingLayout({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Title & Progress Row */} {/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
<View style={styles.titleRow}> <View style={styles.titleRow}>
<View style={styles.titleBlock}>
{showGreeting && (
<Text style={styles.greetingText}>{t('onboardingSurvey.greeting', { name: displayName })}</Text>
)}
<Text style={styles.questionTitle}>{title}</Text> <Text style={styles.questionTitle}>{title}</Text>
</View>
<Text style={styles.progressText}>({currentStep}/{totalSteps})</Text> <Text style={styles.progressText}>({currentStep}/{totalSteps})</Text>
</View> </View>
{/* Content */} {/* Contentstep 切换时滑动 + 淡入 */}
<View style={styles.content}> <Animated.View
style={[
styles.content,
{
opacity,
transform: [{ translateX }],
},
]}
>
{children} {children}
</View> </Animated.View>
</SafeAreaView> </SafeAreaView>
</View> </View>
); );
@@ -112,18 +171,27 @@ const styles = StyleSheet.create({
alignItems: 'flex-end', alignItems: 'flex-end',
paddingHorizontal: 20, paddingHorizontal: 20,
marginTop: 20, marginTop: 20,
marginBottom: 20, marginBottom: 8,
},
titleBlock: {
flex: 1,
justifyContent: 'flex-end',
},
greetingText: {
fontSize: 22,
color: OnboardingColors.questionTitle,
fontFamily: OnboardingFont.question,
marginBottom: 4,
}, },
questionTitle: { questionTitle: {
fontSize: 22, fontSize: 22,
color: OnboardingColors.questionTitle, color: OnboardingColors.questionTitle,
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif', fontFamily: OnboardingFont.question,
flex: 1,
}, },
progressText: { progressText: {
fontSize: 18, fontSize: 18,
color: OnboardingColors.textProgress, color: OnboardingColors.textProgress,
fontFamily: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif', fontFamily: OnboardingFont.question,
marginLeft: 10, marginLeft: 10,
}, },
content: { content: {

View File

@@ -1,20 +1,25 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native'; import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { OnboardingColors } from '@/constants/OnboardingTheme';
import AddIcon from '@/assets/images/icon/add_icon.svg'; import AddIcon from '@/assets/images/icon/add_icon.svg';
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg'; import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg'; import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
const { height } = Dimensions.get('window');
interface ReminderStepProps { interface ReminderStepProps {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
onFinish: () => void; onFinish: () => void;
onSkip?: () => void;
} }
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) { export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handleReduce = () => { const handleReduce = () => {
// 本页最小为 1不接收提醒请使用右上角 Skip
if (value > 1) onChange(value - 1); if (value > 1) onChange(value - 1);
}; };
@@ -31,7 +36,9 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
<View style={styles.numberWrapper}> <View style={styles.numberWrapper}>
<Text style={styles.numberText}>{value}</Text> <Text style={styles.numberText}>{value}</Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>
{value === 1 ? t('dailyReminder.timesUnitSingular') : t('dailyReminder.timesUnit')}
</Text>
</View> </View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}> <TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
@@ -39,7 +46,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.footer}> <View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}> <TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<BtnClicked width={87} height={57} /> <BtnClicked width={87} height={57} />
</TouchableOpacity> </TouchableOpacity>
@@ -83,7 +90,6 @@ const styles = StyleSheet.create({
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: height * 0.12,
alignItems: 'center', alignItems: 'center',
} },
}); });

View File

@@ -1,13 +1,10 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native'; import { View, StyleSheet, TouchableOpacity, ScrollView, Text } from 'react-native';
import { OnboardingColors } from '@/constants/OnboardingTheme'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { SerifText } from './SerifText'; import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
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';
const { height } = Dimensions.get('window');
interface Option { interface Option {
id: string; id: string;
label: string; label: string;
@@ -23,32 +20,36 @@ interface SelectionStepProps {
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) { export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
const hasSelection = selectedIds.length > 0; const hasSelection = selectedIds.length > 0;
const insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16;
const footerButtonHeight = 57;
// 底部留白加大,避免最后一项与按钮边框视觉重叠
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}> <ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
>
{options.map((option) => { {options.map((option) => {
const isSelected = selectedIds.includes(option.id); const isSelected = selectedIds.includes(option.id);
return ( return (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
style={styles.optionCard} style={[styles.optionCard, isSelected && styles.optionCardSelected]}
onPress={() => onToggle(option.id)} onPress={() => onToggle(option.id)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<SerifText style={styles.optionText}>{option.label}</SerifText> <Text style={styles.optionText}>{option.label}</Text>
{isSelected && (
<View style={styles.iconWrapper}>
<SelectedIcon width={20} height={20} />
</View>
)}
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
</ScrollView> </ScrollView>
{/* 底部按钮:距离底部 12% 高度 */} {/* 底部按钮:距离底部 12% 高度 */}
<View style={styles.footer}> <View style={[styles.footer, { bottom: footerBottom }]}>
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}> <TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />} {hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
</TouchableOpacity> </TouchableOpacity>
@@ -60,10 +61,13 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
paddingTop: 20, paddingTop: 8,
},
scroll: {
flex: 1,
}, },
optionsList: { optionsList: {
paddingBottom: 150, // 为底部按钮留出空间 // paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
}, },
optionCard: { optionCard: {
width: '100%', width: '100%',
@@ -72,7 +76,7 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'center',
paddingHorizontal: 24, paddingHorizontal: 24,
marginBottom: 12, marginBottom: 12,
shadowColor: '#000', shadowColor: '#000',
@@ -81,18 +85,17 @@ const styles = StyleSheet.create({
shadowRadius: 10, shadowRadius: 10,
elevation: 2, elevation: 2,
}, },
optionCardSelected: {
backgroundColor: OnboardingColors.cardSelected,
},
optionText: { optionText: {
fontSize: 18, fontSize: 18,
color: OnboardingColors.textPrimary, color: OnboardingColors.textPrimary,
fontWeight: '500', fontWeight: '500',
flex: 1, fontFamily: OnboardingFont.question,
},
iconWrapper: {
marginLeft: 10,
}, },
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: height * 0.12,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', alignItems: 'center',

View File

@@ -1,3 +1,10 @@
import { Platform } from 'react-native';
/** 与 onboarding 问题标题一致的字体PingFang TC / sans-serif */
export const OnboardingFont = {
question: Platform.OS === 'ios' ? 'PingFang TC' : 'sans-serif',
};
export const OnboardingColors = { export const OnboardingColors = {
background: '#FFF4EA', background: '#FFF4EA',
textPrimary: '#772F00', textPrimary: '#772F00',

View File

@@ -1,128 +0,0 @@
import WidgetKit
import SwiftUI
struct Provider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
completion(SimpleEntry(date: Date()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
// V1
let entry = SimpleEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
}
struct MindfulnessWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
var body: some View {
switch family {
case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
}
}
private func smallView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.15, green: 0.18, blue: 0.26)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline).foregroundStyle(.white)
Text(text)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(4)
Spacer(minLength: 0)
}
.padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.10, green: 0.12, blue: 0.18)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.headline).foregroundStyle(.white)
Text(text)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(5)
Spacer(minLength: 0)
}
Spacer(minLength: 0)
}
.padding(16)
}
.widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
LinearGradient(
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.17, green: 0.22, blue: 0.32)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.title3)
.foregroundStyle(.white)
.bold()
Text(text)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.92))
.lineLimit(7)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.footnote)
.foregroundStyle(Color.white.opacity(0.7))
}
.padding(18)
}
.widgetURL(deepLink)
}
}
struct MindfulnessWidget: Widget {
let kind: String = "MindfulnessWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MindfulnessWidgetEntryView(entry: entry)
}
.configurationDisplayName("正念")
.description("一段温柔提醒,陪你回到当下。")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}

View File

@@ -1,12 +0,0 @@
import WidgetKit
import SwiftUI
// Widget Extension @main
@main
struct MindfulnessWidgetBundle: WidgetBundle {
var body: some Widget {
MindfulnessWidget()
EmotionWidget()
}
}

View File

@@ -1,12 +0,0 @@
# MindfulnessWidgetWidgetKit 扩展骨架)
本目录提供 iOS WidgetV1 写死文案)的 SwiftUI 代码骨架。
注意:**仅把文件放进仓库还不够**,你还需要在 Xcode 中创建 Widget Extension target并把这些文件加入 target。
## 目标
- 支持 Small/Medium/Large 三种尺寸
- 展示写死文案
- 点击小组件跳转到 App 的 Home`client:///(app)/home`

View File

@@ -67,5 +67,84 @@ target 'client' do
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES' build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
end end
end end
# 修复Xcode 编译阶段找不到 Expo 相关 modulemap
# 现象PrecompileSwiftBridgingHeader 报错 module map file '.../Build/Products/.../Expo/Expo.modulemap' not found
# 原因Pods-client 的 xcconfig 把 -fmodule-map-file 指向了 ${PODS_CONFIGURATION_BUILD_DIR},但该文件在构建早期并不存在。
# 方案:将这些 modulemap 路径改为 Pods 内稳定存在的 Target Support Files 路径。
def patch_pods_client_xcconfig!(path)
return unless File.exist?(path)
s = File.read(path)
# expo-dev-* 的 modulemap 文件名与 module 名不同,需要单独映射
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-launcher/expo-dev-launcher.modulemap')
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-menu/expo-dev-menu.modulemap')
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu-interface/EXDevMenuInterface.modulemap',
'${PODS_ROOT}/Target Support Files/expo-dev-menu-interface/expo-dev-menu-interface.modulemap')
# 通用映射:${PODS_CONFIGURATION_BUILD_DIR}/<Pod>/<Pod>.modulemap -> ${PODS_ROOT}/Target Support Files/<Pod>/<Pod>.modulemap
s = s.gsub(/\$\{PODS_CONFIGURATION_BUILD_DIR\}\/([^\/]+)\/\1\.modulemap/,
'${PODS_ROOT}/Target Support Files/\1/\1.modulemap')
File.write(path, s)
end
support_dir = File.join(__dir__, 'Pods', 'Target Support Files', 'Pods-client')
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.debug.xcconfig'))
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.release.xcconfig'))
# 修复:缺失 [CP] Copy XCFrameworks 阶段时React/Expo 的 XCFramework 中间产物不会生成,
# 导致 Swift 报 no such module 'React' 等。
# 方案:在 [CP] Embed Pods Frameworks 脚本中,先执行各个 *-xcframeworks.sh 生成中间产物。
def patch_pods_client_frameworks_sh!(path)
return unless File.exist?(path)
s = File.read(path)
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates\n"
return if s.include?(marker)
insert = marker +
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
"fi\n\n"
s = s.sub(/^if \[\[ \"\$CONFIGURATION\" == \"Debug\" \]\]; then\n/, insert + "if [[ \"$CONFIGURATION\" == \"Debug\" ]]; then\n")
File.write(path, s)
end
patch_pods_client_frameworks_sh!(File.join(support_dir, 'Pods-client-frameworks.sh'))
# 让 React/ReactNativeDependencies/hermes 的 XCFramework 切片在编译 Swift 之前就准备好,
# 否则会在 AppDelegate.swift 的 `import React` 阶段报 no such module。
def patch_expo_configure_project_sh!(path)
return unless File.exist?(path)
s = File.read(path)
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates (before Swift compile)\n"
return if s.include?(marker)
insert = marker +
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
"fi\n" \
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
"fi\n\n"
# 插在首次调用 with_node 之前即可(不能用 ^,因为 with_node 不在文件开头)
s = s.sub("with_node \\\n", insert + "with_node \\\n")
File.write(path, s)
end
patch_expo_configure_project_sh!(File.join(support_dir, 'expo-configure-project.sh'))
end end
end end

View File

@@ -3,6 +3,9 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXConstants (18.0.13): - EXConstants (18.0.13):
- ExpoModulesCore - ExpoModulesCore
- EXJSONUtils (0.15.0)
- EXManifests (1.0.10):
- ExpoModulesCore
- EXNotifications (0.32.16): - EXNotifications (0.32.16):
- ExpoModulesCore - ExpoModulesCore
- Expo (54.0.32): - Expo (54.0.32):
@@ -30,8 +33,183 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- expo-dev-client (6.0.20):
- EXManifests
- expo-dev-launcher
- expo-dev-menu
- expo-dev-menu-interface
- EXUpdatesInterface
- expo-dev-launcher (6.0.20):
- EXManifests
- expo-dev-launcher/Main (= 6.0.20)
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactAppDependencyProvider
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-launcher/Main (6.0.20):
- EXManifests
- expo-dev-launcher/Unsafe
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactAppDependencyProvider
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-launcher/Unsafe (6.0.20):
- EXManifests
- expo-dev-menu
- expo-dev-menu-interface
- ExpoModulesCore
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTAppDelegate
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactAppDependencyProvider
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu (7.0.18):
- expo-dev-menu/Main (= 7.0.18)
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
- 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
- expo-dev-menu-interface (2.0.0)
- expo-dev-menu/Main (7.0.18):
- EXManifests
- expo-dev-menu-interface
- ExpoModulesCore
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-jsinspector
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
- 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
- ExpoAsset (12.0.12): - ExpoAsset (12.0.12):
- ExpoModulesCore - ExpoModulesCore
- ExpoCrypto (15.0.8):
- ExpoModulesCore
- ExpoDevice (8.0.10):
- ExpoModulesCore
- ExpoFileSystem (19.0.21): - ExpoFileSystem (19.0.21):
- ExpoModulesCore - ExpoModulesCore
- ExpoFont (14.0.11): - ExpoFont (14.0.11):
@@ -74,6 +252,8 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- ExpoWebBrowser (15.0.10): - ExpoWebBrowser (15.0.10):
- ExpoModulesCore - ExpoModulesCore
- EXUpdatesInterface (2.0.0):
- ExpoModulesCore
- FBLazyVector (0.81.5) - FBLazyVector (0.81.5)
- hermes-engine (0.81.5): - hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5) - hermes-engine/Pre-built (= 0.81.5)
@@ -1798,6 +1978,28 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- ReactNativeDependencies - ReactNativeDependencies
- Yoga - Yoga
- RNGestureHandler (2.30.0):
- 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
- RNReanimated (4.1.6): - RNReanimated (4.1.6):
- hermes-engine - hermes-engine
- RCTRequired - RCTRequired
@@ -2038,291 +2240,330 @@ PODS:
- Yoga (0.0.0) - Yoga (0.0.0)
DEPENDENCIES: DEPENDENCIES:
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)" - EXApplication (from `../node_modules/expo-application/ios`)
- "EXConstants (from `../node_modules/.pnpm/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_/node_modules/expo-constants/ios`)" - EXConstants (from `../node_modules/expo-constants/ios`)
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)" - EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)" - EXManifests (from `../node_modules/expo-manifests/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`)" - EXNotifications (from `../node_modules/expo-notifications/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`)" - Expo (from `../node_modules/expo`)
- "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`)" - expo-dev-client (from `../node_modules/expo-dev-client/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`)" - expo-dev-launcher (from `../node_modules/expo-dev-launcher`)
- "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`)" - expo-dev-menu (from `../node_modules/expo-dev-menu`)
- "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`)" - expo-dev-menu-interface (from `../node_modules/expo-dev-menu-interface/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`)" - ExpoAsset (from `../node_modules/expo-asset/ios`)
- "ExpoLocalization (from `../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios`)" - ExpoCrypto (from `../node_modules/expo-crypto/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`)" - ExpoDevice (from `../node_modules/expo-device/ios`)
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)" - ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_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-web-browser/ios`)" - ExpoFont (from `../node_modules/expo-font/ios`)
- "FBLazyVector (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/Libraries/FBLazyVector`)" - ExpoHead (from `../node_modules/expo-router/ios`)
- "hermes-engine (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/sdks/hermes-engine/hermes-engine.podspec`)" - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
- "RCTDeprecation (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/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)" - ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
- "RCTRequired (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/Libraries/Required`)" - ExpoLinking (from `../node_modules/expo-linking/ios`)
- "RCTTypeSafety (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/Libraries/TypeSafety`)" - ExpoLocalization (from `../node_modules/expo-localization/ios`)
- "React (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/`)" - ExpoModulesCore (from `../node_modules/expo-modules-core`)
- "React-callinvoker (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/callinvoker`)" - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
- "React-Core (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/`)" - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- "React-Core-prebuilt (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/React-Core-prebuilt.podspec`)" - EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- "React-Core/RCTWebSocket (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/`)" - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- "React-CoreModules (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/React/CoreModules`)" - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- "React-cxxreact (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/cxxreact`)" - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- "React-debug (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/react/debug`)" - RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- "React-defaultsnativemodule (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/react/nativemodule/defaults`)" - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
- "React-domnativemodule (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/react/nativemodule/dom`)" - React (from `../node_modules/react-native/`)
- "React-Fabric (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`)" - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
- "React-FabricComponents (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`)" - React-Core (from `../node_modules/react-native/`)
- "React-FabricImage (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`)" - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`)
- "React-featureflags (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/react/featureflags`)" - React-Core/RCTWebSocket (from `../node_modules/react-native/`)
- "React-featureflagsnativemodule (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/react/nativemodule/featureflags`)" - React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
- "React-graphics (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/react/renderer/graphics`)" - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
- "React-hermes (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/hermes`)" - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
- "React-idlecallbacksnativemodule (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/react/nativemodule/idlecallbacks`)" - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
- "React-ImageManager (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/react/renderer/imagemanager/platform/ios`)" - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
- "React-jserrorhandler (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/jserrorhandler`)" - React-Fabric (from `../node_modules/react-native/ReactCommon`)
- "React-jsi (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/jsi`)" - React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
- "React-jsiexecutor (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/jsiexecutor`)" - React-FabricImage (from `../node_modules/react-native/ReactCommon`)
- "React-jsinspector (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/jsinspector-modern`)" - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
- "React-jsinspectorcdp (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/jsinspector-modern/cdp`)" - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
- "React-jsinspectornetwork (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/jsinspector-modern/network`)" - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
- "React-jsinspectortracing (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/jsinspector-modern/tracing`)" - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
- "React-jsitooling (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/jsitooling`)" - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
- "React-jsitracing (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/hermes/executor/`)" - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
- "React-logger (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/logger`)" - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
- "React-Mapbuffer (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`)" - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
- "React-microtasksnativemodule (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/react/nativemodule/microtasks`)" - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- "react-native-safe-area-context (from `../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context`)" - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
- "React-NativeModulesApple (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/react/nativemodule/core/platform/ios`)" - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`)
- "React-oscompat (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/oscompat`)" - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`)
- "React-perflogger (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/reactperflogger`)" - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`)
- "React-performancetimeline (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/react/performance/timeline`)" - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`)
- "React-RCTActionSheet (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/Libraries/ActionSheetIOS`)" - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
- "React-RCTAnimation (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/Libraries/NativeAnimation`)" - React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- "React-RCTAppDelegate (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/Libraries/AppDelegate`)" - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- "React-RCTBlob (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/Libraries/Blob`)" - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- "React-RCTFabric (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/React`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- "React-RCTFBReactNativeSpec (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/React`)" - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- "React-RCTImage (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/Libraries/Image`)" - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`)
- "React-RCTLinking (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/Libraries/LinkingIOS`)" - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- "React-RCTNetwork (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/Libraries/Network`)" - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
- "React-RCTRuntime (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/React/Runtime`)" - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- "React-RCTSettings (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/Libraries/Settings`)" - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
- "React-RCTText (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/Libraries/Text`)" - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
- "React-RCTVibration (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/Libraries/Vibration`)" - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
- "React-rendererconsistency (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/react/renderer/consistency`)" - React-RCTFabric (from `../node_modules/react-native/React`)
- "React-renderercss (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/react/renderer/css`)" - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
- "React-rendererdebug (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/react/renderer/debug`)" - React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
- "React-RuntimeApple (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/react/runtime/platform/ios`)" - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
- "React-RuntimeCore (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/react/runtime`)" - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
- "React-runtimeexecutor (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/runtimeexecutor`)" - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`)
- "React-RuntimeHermes (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/react/runtime`)" - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
- "React-runtimescheduler (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/react/renderer/runtimescheduler`)" - React-RCTText (from `../node_modules/react-native/Libraries/Text`)
- "React-timing (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/react/timing`)" - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
- "React-utils (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/react/utils`)" - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
- React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`)
- React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
- React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
- React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
- React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
- React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactAppDependencyProvider (from `build/generated/ios`) - ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`)
- "ReactCommon/turbomodule/core (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`)" - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "ReactNativeDependencies (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/third-party-podspecs/ReactNativeDependencies.podspec`)" - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- "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`)" - "RNCAsyncStorage (from `../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`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- "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`)" - RNReanimated (from `../node_modules/react-native-reanimated`)
- "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`)" - RNScreens (from `../node_modules/react-native-screens`)
- "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`)" - RNSVG (from `../node_modules/react-native-svg`)
- "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`)" - RNWorklets (from `../node_modules/react-native-worklets`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
EXApplication: EXApplication:
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios" :path: "../node_modules/expo-application/ios"
EXConstants: EXConstants:
:path: "../node_modules/.pnpm/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_/node_modules/expo-constants/ios" :path: "../node_modules/expo-constants/ios"
EXJSONUtils:
:path: "../node_modules/expo-json-utils/ios"
EXManifests:
:path: "../node_modules/expo-manifests/ios"
EXNotifications: EXNotifications:
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios" :path: "../node_modules/expo-notifications/ios"
Expo: Expo:
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo" :path: "../node_modules/expo"
expo-dev-client:
:path: "../node_modules/expo-dev-client/ios"
expo-dev-launcher:
:path: "../node_modules/expo-dev-launcher"
expo-dev-menu:
:path: "../node_modules/expo-dev-menu"
expo-dev-menu-interface:
:path: "../node_modules/expo-dev-menu-interface/ios"
ExpoAsset: ExpoAsset:
: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/expo-asset/ios"
ExpoCrypto:
:path: "../node_modules/expo-crypto/ios"
ExpoDevice:
:path: "../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/expo-file-system/ios"
ExpoFont: ExpoFont:
:path: "../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" :path: "../node_modules/expo-font/ios"
ExpoHead: ExpoHead:
: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" :path: "../node_modules/expo-router/ios"
ExpoKeepAwake: 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" :path: "../node_modules/expo-keep-awake/ios"
ExpoLinearGradient: 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" :path: "../node_modules/expo-linear-gradient/ios"
ExpoLinking: 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" :path: "../node_modules/expo-linking/ios"
ExpoLocalization: ExpoLocalization:
:path: "../node_modules/.pnpm/expo-localization@17.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-localization/ios" :path: "../node_modules/expo-localization/ios"
ExpoModulesCore: ExpoModulesCore:
:path: "../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" :path: "../node_modules/expo-modules-core"
ExpoSplashScreen: ExpoSplashScreen:
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios" :path: "../node_modules/expo-splash-screen/ios"
ExpoWebBrowser: ExpoWebBrowser:
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_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-web-browser/ios" :path: "../node_modules/expo-web-browser/ios"
EXUpdatesInterface:
:path: "../node_modules/expo-updates-interface/ios"
FBLazyVector: FBLazyVector:
:path: "../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/Libraries/FBLazyVector" :path: "../node_modules/react-native/Libraries/FBLazyVector"
hermes-engine: hermes-engine:
:podspec: "../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/sdks/hermes-engine/hermes-engine.podspec" :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
RCTDeprecation: RCTDeprecation:
:path: "../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/ReactApple/Libraries/RCTFoundation/RCTDeprecation" :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired: RCTRequired:
:path: "../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/Libraries/Required" :path: "../node_modules/react-native/Libraries/Required"
RCTTypeSafety: RCTTypeSafety:
:path: "../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/Libraries/TypeSafety" :path: "../node_modules/react-native/Libraries/TypeSafety"
React: React:
:path: "../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/" :path: "../node_modules/react-native/"
React-callinvoker: React-callinvoker:
:path: "../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/callinvoker" :path: "../node_modules/react-native/ReactCommon/callinvoker"
React-Core: React-Core:
:path: "../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/" :path: "../node_modules/react-native/"
React-Core-prebuilt: React-Core-prebuilt:
:podspec: "../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/React-Core-prebuilt.podspec" :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec"
React-CoreModules: React-CoreModules:
:path: "../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/React/CoreModules" :path: "../node_modules/react-native/React/CoreModules"
React-cxxreact: React-cxxreact:
:path: "../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/cxxreact" :path: "../node_modules/react-native/ReactCommon/cxxreact"
React-debug: React-debug:
:path: "../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/react/debug" :path: "../node_modules/react-native/ReactCommon/react/debug"
React-defaultsnativemodule: React-defaultsnativemodule:
:path: "../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/react/nativemodule/defaults" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
React-domnativemodule: React-domnativemodule:
:path: "../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/react/nativemodule/dom" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
React-Fabric: React-Fabric:
:path: "../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" :path: "../node_modules/react-native/ReactCommon"
React-FabricComponents: React-FabricComponents:
:path: "../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" :path: "../node_modules/react-native/ReactCommon"
React-FabricImage: React-FabricImage:
:path: "../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" :path: "../node_modules/react-native/ReactCommon"
React-featureflags: React-featureflags:
:path: "../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/react/featureflags" :path: "../node_modules/react-native/ReactCommon/react/featureflags"
React-featureflagsnativemodule: React-featureflagsnativemodule:
:path: "../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/react/nativemodule/featureflags" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
React-graphics: React-graphics:
:path: "../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/react/renderer/graphics" :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
React-hermes: React-hermes:
:path: "../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/hermes" :path: "../node_modules/react-native/ReactCommon/hermes"
React-idlecallbacksnativemodule: React-idlecallbacksnativemodule:
:path: "../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/react/nativemodule/idlecallbacks" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
React-ImageManager: React-ImageManager:
:path: "../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/react/renderer/imagemanager/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
React-jserrorhandler: React-jserrorhandler:
:path: "../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/jserrorhandler" :path: "../node_modules/react-native/ReactCommon/jserrorhandler"
React-jsi: React-jsi:
:path: "../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/jsi" :path: "../node_modules/react-native/ReactCommon/jsi"
React-jsiexecutor: React-jsiexecutor:
:path: "../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/jsiexecutor" :path: "../node_modules/react-native/ReactCommon/jsiexecutor"
React-jsinspector: React-jsinspector:
:path: "../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/jsinspector-modern" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
React-jsinspectorcdp: React-jsinspectorcdp:
:path: "../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/jsinspector-modern/cdp" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp"
React-jsinspectornetwork: React-jsinspectornetwork:
:path: "../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/jsinspector-modern/network" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network"
React-jsinspectortracing: React-jsinspectortracing:
:path: "../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/jsinspector-modern/tracing" :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing"
React-jsitooling: React-jsitooling:
:path: "../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/jsitooling" :path: "../node_modules/react-native/ReactCommon/jsitooling"
React-jsitracing: React-jsitracing:
:path: "../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/hermes/executor/" :path: "../node_modules/react-native/ReactCommon/hermes/executor/"
React-logger: React-logger:
:path: "../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/logger" :path: "../node_modules/react-native/ReactCommon/logger"
React-Mapbuffer: React-Mapbuffer:
:path: "../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" :path: "../node_modules/react-native/ReactCommon"
React-microtasksnativemodule: React-microtasksnativemodule:
:path: "../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/react/nativemodule/microtasks" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/.pnpm/react-native-safe-area-context@5.6.2_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1_azuxgonsvxb2yngtegtuvyxcpi/node_modules/react-native-safe-area-context" :path: "../node_modules/react-native-safe-area-context"
React-NativeModulesApple: React-NativeModulesApple:
:path: "../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/react/nativemodule/core/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-oscompat: React-oscompat:
:path: "../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/oscompat" :path: "../node_modules/react-native/ReactCommon/oscompat"
React-perflogger: React-perflogger:
:path: "../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/reactperflogger" :path: "../node_modules/react-native/ReactCommon/reactperflogger"
React-performancetimeline: React-performancetimeline:
:path: "../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/react/performance/timeline" :path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
React-RCTActionSheet: React-RCTActionSheet:
:path: "../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/Libraries/ActionSheetIOS" :path: "../node_modules/react-native/Libraries/ActionSheetIOS"
React-RCTAnimation: React-RCTAnimation:
:path: "../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/Libraries/NativeAnimation" :path: "../node_modules/react-native/Libraries/NativeAnimation"
React-RCTAppDelegate: React-RCTAppDelegate:
:path: "../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/Libraries/AppDelegate" :path: "../node_modules/react-native/Libraries/AppDelegate"
React-RCTBlob: React-RCTBlob:
:path: "../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/Libraries/Blob" :path: "../node_modules/react-native/Libraries/Blob"
React-RCTFabric: React-RCTFabric:
:path: "../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/React" :path: "../node_modules/react-native/React"
React-RCTFBReactNativeSpec: React-RCTFBReactNativeSpec:
:path: "../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/React" :path: "../node_modules/react-native/React"
React-RCTImage: React-RCTImage:
:path: "../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/Libraries/Image" :path: "../node_modules/react-native/Libraries/Image"
React-RCTLinking: React-RCTLinking:
:path: "../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/Libraries/LinkingIOS" :path: "../node_modules/react-native/Libraries/LinkingIOS"
React-RCTNetwork: React-RCTNetwork:
:path: "../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/Libraries/Network" :path: "../node_modules/react-native/Libraries/Network"
React-RCTRuntime: React-RCTRuntime:
:path: "../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/React/Runtime" :path: "../node_modules/react-native/React/Runtime"
React-RCTSettings: React-RCTSettings:
:path: "../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/Libraries/Settings" :path: "../node_modules/react-native/Libraries/Settings"
React-RCTText: React-RCTText:
:path: "../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/Libraries/Text" :path: "../node_modules/react-native/Libraries/Text"
React-RCTVibration: React-RCTVibration:
:path: "../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/Libraries/Vibration" :path: "../node_modules/react-native/Libraries/Vibration"
React-rendererconsistency: React-rendererconsistency:
:path: "../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/react/renderer/consistency" :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
React-renderercss: React-renderercss:
:path: "../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/react/renderer/css" :path: "../node_modules/react-native/ReactCommon/react/renderer/css"
React-rendererdebug: React-rendererdebug:
:path: "../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/react/renderer/debug" :path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
React-RuntimeApple: React-RuntimeApple:
:path: "../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/react/runtime/platform/ios" :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
React-RuntimeCore: React-RuntimeCore:
:path: "../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/react/runtime" :path: "../node_modules/react-native/ReactCommon/react/runtime"
React-runtimeexecutor: React-runtimeexecutor:
:path: "../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/runtimeexecutor" :path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
React-RuntimeHermes: React-RuntimeHermes:
:path: "../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/react/runtime" :path: "../node_modules/react-native/ReactCommon/react/runtime"
React-runtimescheduler: React-runtimescheduler:
:path: "../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/react/renderer/runtimescheduler" :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
React-timing: React-timing:
:path: "../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/react/timing" :path: "../node_modules/react-native/ReactCommon/react/timing"
React-utils: React-utils:
:path: "../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/react/utils" :path: "../node_modules/react-native/ReactCommon/react/utils"
ReactAppDependencyProvider: ReactAppDependencyProvider:
:path: build/generated/ios :path: build/generated/ios
ReactCodegen: ReactCodegen:
:path: build/generated/ios :path: build/generated/ios
ReactCommon: ReactCommon:
:path: "../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" :path: "../node_modules/react-native/ReactCommon"
ReactNativeDependencies: ReactNativeDependencies:
:podspec: "../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/third-party-podspecs/ReactNativeDependencies.podspec" :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
RNCAsyncStorage: RNCAsyncStorage:
:path: "../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" :path: "../node_modules/@react-native-async-storage/async-storage"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNReanimated: RNReanimated:
: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" :path: "../node_modules/react-native-reanimated"
RNScreens: 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" :path: "../node_modules/react-native-screens"
RNSVG: 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" :path: "../node_modules/react-native-svg"
RNWorklets: 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" :path: "../node_modules/react-native-worklets"
Yoga: Yoga:
:path: "../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" :path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186 EXApplication: 1e98d4b1dccdf30627f92917f4b2c5a53c330e5f
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80 EXManifests: a8d97683e5c7a3b026ffbd58559c64dc655b747b
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b EXNotifications: 9eec98712cc814ceff916d876cb53859003b0597
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664 expo-dev-client: 425ee077d6754a98cfe3a2e2410d29b440b24c9d
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29 expo-dev-launcher: a4f4cdef064ab1fb8621e5b8c7c457cd6e9568c3
ExpoKeepAwake: 44bf6715bc1d2ddb17afe19d927cd039cda123f0 expo-dev-menu: 05b18812110c175814c6af0d09dd658abcc5e00d
ExpoLinearGradient: 814a21fc4056c3cf606e4f19e31e47074c5b5a86 expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
ExpoLinking: ebf543fd411d56375cb4eee07f6ab4e31c7ad959 ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
ExpoLocalization: 6ac6f326210f0a3141ef6f58ab8f8f4ed003b485 ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798 ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588 ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47
ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d
ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
@@ -2330,73 +2571,74 @@ SPEC CHECKSUMS:
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
React: 914f8695f9bf38e6418228c2ffb70021e559f92f React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
React-timing: 03c7217455d2bff459b27a3811be25796b600f47 React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a React-utils: 6e2035b53d087927768649a11a26c4e092448e34
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
ReactCodegen: 88a1f4643f15841573f833b895bfa2a0c6cb4e7f ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c RNGestureHandler: e0d0bce5599f6120b7adf90c38d2805e2935795f
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061 PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 56;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -11,7 +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 */; };
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 */; };
@@ -50,7 +53,9 @@
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>"; };
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>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
@@ -58,7 +63,8 @@
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; }; EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = clientRelease.entitlements; path = client/clientRelease.entitlements; sourceTree = "<group>"; };
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "情绪小组件ExtensionRelease.entitlements"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; }; F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -66,7 +72,7 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
EmotionWidget.swift, EmotionWidget.swift,
@@ -77,7 +83,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; }; EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
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 */
@@ -86,6 +103,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */, 1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -104,8 +122,10 @@
13B07FAE1A68108700A75B9A /* client */ = { 13B07FAE1A68108700A75B9A /* client */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */, EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */, F11748412D0307B40044C1D9 /* AppDelegate.swift */,
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */,
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */,
F11748442D0722820044C1D9 /* client-Bridging-Header.h */, F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */, BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB51A68108700A75B9A /* Images.xcassets */,
@@ -145,6 +165,7 @@
83CBB9F61A601CBA00E9B192 = { 83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
13B07FAE1A68108700A75B9A /* client */, 13B07FAE1A68108700A75B9A /* client */,
832341AE1AAA6A7D00B99B32 /* Libraries */, 832341AE1AAA6A7D00B99B32 /* Libraries */,
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */, EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
@@ -189,7 +210,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>";
@@ -361,12 +382,15 @@
"${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",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
); );
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputPaths = ( outputPaths = (
@@ -374,12 +398,15 @@
"${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",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.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-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
@@ -441,6 +468,8 @@
files = ( files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */, B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -448,7 +477,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;
}; };
@@ -472,7 +501,9 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements; CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)", "$(inherited)",
"FB_SONARKIT_ENABLED=1", "FB_SONARKIT_ENABLED=1",
@@ -492,7 +523,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness; PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama; PRODUCT_NAME = HeyMama;
SKIP_INSTALL = YES; SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -512,11 +543,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements; CODE_SIGN_ENTITLEMENTS = client/clientRelease.entitlements;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 4;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WS92GPX9H2; DEVELOPMENT_TEAM = WS92GPX9H2;
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES; DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
INFOPLIST_FILE = client/Info.plist; INFOPLIST_FILE = client/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -532,7 +564,7 @@
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness; PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama; PRODUCT_NAME = HeyMama;
SKIP_INSTALL = YES; SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
@@ -600,7 +632,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../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"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = NO; SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -659,7 +691,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../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"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = NO; SKIP_INSTALL = NO;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -683,9 +715,11 @@
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;
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -734,6 +768,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;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 4;

View File

@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
customArchiveName = "Hey Mama"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,20 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "2620"
version = "2.2"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<AutocreatedTestPlanReference>
</AutocreatedTestPlanReference>
</BuildActionEntry>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
buildForRunning = "YES" buildForRunning = "YES"
@@ -81,6 +72,26 @@
</AnalyzeAction> </AnalyzeAction>
<ArchiveAction <ArchiveAction
buildConfiguration = "Release" buildConfiguration = "Release"
customArchiveName = "Hey Mama"
revealArchiveInOrganizer = "YES"> revealArchiveInOrganizer = "YES">
<PostActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "&#x4fee;&#x590d;&#x5f52;&#x6863;&#x5934;&#x4fe1;&#x606f;&#xff08;&#x907f;&#x514d; Generic Xcode Archive&#xff09;"
scriptText = "bash &quot;${SRCROOT}/scripts/fix-xcarchive-header.sh&quot; &quot;${ARCHIVE_PATH}&quot;&#10;"
shellToInvoke = "/bin/sh">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PostActions>
</ArchiveAction> </ArchiveAction>
</Scheme> </Scheme>

View File

@@ -0,0 +1,53 @@
import Foundation
import React
import WidgetKit
/**
* App Group RN Bridge
*
*
* - suiteNamegroup.com.damer.mindfulness App Widget Extension entitlements
* - 使 JSON JS
*/
@objc(AppGroupStorage)
final class AppGroupStorage: NSObject {
// AppGroupStorageBridge.m RCT_EXTERN_MODULE RN
// / RCTBridgeModule Archive
@objc static func requiresMainQueueSetup() -> Bool { false }
private let suiteName = "group.com.damer.mindfulness"
private func defaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName)
}
@objc(setString:value:resolver:rejecter:)
func setString(_ key: String, value: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
guard let d = defaults() else {
reject("E_APP_GROUP", "无法初始化 App Group UserDefaultssuiteName=\(suiteName)", nil)
return
}
d.set(value, forKey: key)
resolve(nil)
}
@objc(getString:resolver:rejecter:)
func getString(_ key: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
guard let d = defaults() else {
reject("E_APP_GROUP", "无法初始化 App Group UserDefaultssuiteName=\(suiteName)", nil)
return
}
let v = d.string(forKey: key)
resolve(v)
}
/**
* Widget
*/
@objc(reloadAllTimelines:rejecter:)
func reloadAllTimelines(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
WidgetCenter.shared.reloadAllTimelines()
resolve(nil)
}
}

View File

@@ -0,0 +1,26 @@
#import <React/RCTBridgeModule.h>
/**
* Swift
*
*
* - React Native Swift RCT_EXTERN_MODULE / RCT_EXTERN_METHOD
* - JS NativeModules.AppGroupStorage undefined
*/
@interface RCT_EXTERN_MODULE(AppGroupStorage, NSObject)
RCT_EXTERN_METHOD(setString:(NSString *)key
value:(NSString *)value
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getString:(NSString *)key
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(reloadAllTimelines:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end

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

@@ -38,6 +38,8 @@
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>

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

@@ -1,3 +1,11 @@
// //
// Use this file to import your target's public headers that you would like to expose to Swift. // Use this file to import your target's public headers that you would like to expose to Swift.
// //
// 说明:
// - 部分环境下仅 `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

@@ -4,5 +4,10 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>production</string> <string>production</string>
<!-- iOS 小组件需要通过 App Group 与主 App 共享数据 -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
# - Organizer 无法识别归档中的主 App即使 Products/Applications/*.app 存在)
#
# 说明:
# - 该脚本的核心作用是让 Organizer 能识别归档里的主 App从而出现“分发/上传 TestFlight”入口。
# - 这类问题通常发生在命令行/CI 归档xcodebuild archive或某些自定义归档流程中
# 导致 .xcarchive/Info.plist 缺少/不完整。
#
# 用法:
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
ARCHIVE_PATH="${1:-}"
if [[ -z "$ARCHIVE_PATH" ]]; then
echo "用法: $0 \"/path/to/xxx.xcarchive\"" >&2
exit 2
fi
if [[ ! -d "$ARCHIVE_PATH" ]]; then
echo "错误:找不到归档目录:$ARCHIVE_PATH" >&2
exit 2
fi
ARCHIVE_INFO_PLIST="$ARCHIVE_PATH/Info.plist"
if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
echo "错误:找不到归档 Info.plist$ARCHIVE_INFO_PLIST" >&2
exit 2
fi
# 归档名:尽量从路径推导,避免依赖 Xcode 环境变量
archive_basename="$(/usr/bin/basename "$ARCHIVE_PATH")"
archive_name="${archive_basename%.xcarchive}"
# 取第一个 App归档里通常只有一个主 App
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
if [[ -z "$APP_PLIST" ]]; then
echo "错误:归档中未找到 Products/Applications/*.app/Info.plist请先确保归档产出包含 .app" >&2
exit 2
fi
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 HeyMama.app
APP_REL_PATH="Applications/$APP_NAME"
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
display_name="$(/usr/bin/plutil -extract CFBundleDisplayName raw -o - "$APP_PLIST" 2>/dev/null || true)"
bundle_name="$(/usr/bin/plutil -extract CFBundleName raw -o - "$APP_PLIST" 2>/dev/null || true)"
# SchemeName 在 Organizer 中会用到,但在某些归档流程里会缺失
scheme_name="${SCHEME_NAME:-}"
if [[ -z "$scheme_name" ]]; then
scheme_name="${archive_name:-}"
fi
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
echo "错误:无法从 App Info.plist 读取 bundle/version/build$APP_PLIST" >&2
exit 2
fi
# 解析 embedded.mobileprovision若存在
profile_name=""
profile_uuid=""
team_id=""
provision_path="$APP_DIR/embedded.mobileprovision"
if [[ -f "$provision_path" ]]; then
decoded="$(/usr/bin/security cms -D -i "$provision_path" 2>/dev/null || true)"
if [[ -n "$decoded" ]]; then
# 使用 plutil 从 xml 中提取字段
profile_name="$(printf "%s" "$decoded" | /usr/bin/plutil -extract Name raw -o - - 2>/dev/null || true)"
profile_uuid="$(printf "%s" "$decoded" | /usr/bin/plutil -extract UUID raw -o - - 2>/dev/null || true)"
team_id="$(printf "%s" "$decoded" | /usr/bin/plutil -extract TeamIdentifier.0 raw -o - - 2>/dev/null || true)"
fi
fi
# 备份一份,防止误操作
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
# 修复归档根字段,避免 Organizer 仍然把它当 Generic Archive
# 参考:标准 .xcarchive/Info.plist 通常包含 Name / SchemeName / ArchiveVersion / CreationDate 等。
# 我们只在缺失时补齐,尽量不改动归档的其他内容。
if ! /usr/bin/plutil -extract Name xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -insert Name -string "${archive_name:-${display_name:-${bundle_name:-}}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
fi
if ! /usr/bin/plutil -extract SchemeName xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -insert SchemeName -string "${scheme_name:-${archive_name:-}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
fi
# 如果已有 ApplicationProperties直接更新关键字段即可
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -replace ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
else
# 新增 ApplicationProperties注意plutil 的空字典/数组类型是 -dictionary / -array
/usr/bin/plutil -insert ApplicationProperties -dictionary "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.Architectures -array "$ARCHIVE_INFO_PLIST"
/usr/bin/plutil -insert ApplicationProperties.Architectures.0 -string "arm64" "$ARCHIVE_INFO_PLIST"
fi
# 可选字段Provisioning Profile 信息(不保证一定存在)
if [[ -n "$profile_name" ]]; then
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST"
fi
if [[ -n "$profile_uuid" ]]; then
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST"
fi
if [[ -n "$team_id" ]]; then
/usr/bin/plutil -replace ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
/usr/bin/plutil -insert ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST"
fi
echo "已修复归档 header$ARCHIVE_INFO_PLIST"
echo "主 App$APP_REL_PATH"
echo "Bundle$bundle_id"
echo "Version/Build$short_version/$build_version"
echo "Name/SchemeName${archive_name:-} / ${scheme_name:-}"
# 轻量自检:确保关键字段存在(不强制失败,避免中断归档)
if ! /usr/bin/plutil -extract ApplicationProperties.ApplicationPath xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
echo "警告:归档 Info.plist 仍缺少 ApplicationProperties.ApplicationPathOrganizer 可能仍显示 Generic Archive" >&2
fi

View File

@@ -1,190 +1,326 @@
import Foundation
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
// V1Small/Medium/Large + Home // Daily Widget Reco App Group /v1/reco/widget
private let appGroupSuiteName = "group.com.damer.mindfulness"
private let keyWidgetConfig = "widget.config.v1"
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
private let keyWidgetDailyReco = "widget.dailyReco.v1"
private let fallbackTextTC = "你已经很努力了,今天也值得被温柔对待。"
private let fallbackTextEN = "Youve been doing great — you deserve kindness today."
private func defaults() -> UserDefaults? {
UserDefaults(suiteName: appGroupSuiteName)
}
private func isoNow() -> String {
ISO8601DateFormatter().string(from: Date())
}
private func localDayKey(_ date: Date = Date()) -> String {
let fmt = DateFormatter()
fmt.calendar = Calendar.current
fmt.timeZone = TimeZone.current
fmt.dateFormat = "yyyy-MM-dd"
return fmt.string(from: date)
}
private func resolveLang() -> String {
// en/tc
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
return preferred.hasPrefix("zh") ? "tc" : "en"
}
private func resolveTitle(lang: String) -> String {
lang == "en" ? "Mindfulness" : "正念"
}
private func resolveFooterHint(lang: String) -> String {
lang == "en" ? "Tap to open the app" : "点我回到 App"
}
private func joinUrl(base: String, path: String) -> String {
let b = base.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
if path.hasPrefix("/") { return "\(b)\(path)" }
return "\(b)/\(path)"
}
private func nextDailyRefreshDate(from now: Date) -> Date {
// 00:1001:00
var cal = Calendar.current
cal.timeZone = TimeZone.current
guard let tomorrow = cal.date(byAdding: .day, value: 1, to: now) else {
return now.addingTimeInterval(60 * 60 * 6)
}
let start = cal.startOfDay(for: tomorrow)
let minDate = cal.date(byAdding: .minute, value: 10, to: start) ?? start.addingTimeInterval(60 * 10)
let maxDate = cal.date(byAdding: .hour, value: 1, to: start) ?? start.addingTimeInterval(60 * 60)
let interval = max(0, maxDate.timeIntervalSince(minDate))
let jitter = interval > 0 ? Double.random(in: 0..<interval) : 0
return minDate.addingTimeInterval(jitter)
}
private func readJsonDict(forKey key: String) -> [String: Any]? {
guard let raw = defaults()?.string(forKey: key) else { return nil }
guard let data = raw.data(using: .utf8) else { return nil }
let obj = try? JSONSerialization.jsonObject(with: data, options: [])
return obj as? [String: Any]
}
private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return }
guard let raw = String(data: data, encoding: .utf8) else { return }
defaults()?.set(raw, forKey: key)
}
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
let lang = (d["lang"] as? String) ?? resolveLang()
let dayKey = d["day_key"] as? String
if let item = d["item"] as? [String: Any], let text = item["text"] as? String, !text.isEmpty {
return (dayKey: dayKey, lang: lang, text: text)
}
return nil
}
private func readApiBaseUrl() -> String? {
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
let base = d["apiBaseUrl"] as? String
return base?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func readUserProfileDict() -> [String: Any]? {
guard let d = readJsonDict(forKey: keyWidgetUserProfile) else { return nil }
return d["user_profile"] as? [String: Any]
}
private func saveDailyReco(lang: String, dayKey: String, contentId: Int, text: String, meta: [String: Any]?) {
var dict: [String: Any] = [
"schema_version": 1,
"saved_at": isoNow(),
"day_key": dayKey,
"lang": lang,
"source": "widget",
"item": [
"content_id": contentId,
"text": text
]
]
if let meta = meta { dict["meta"] = meta }
writeJsonDict(dict, forKey: keyWidgetDailyReco)
}
private func fetchDailyRecoFromServer() async -> (lang: String, text: String, contentId: Int, meta: [String: Any]?)? {
guard let baseUrl = readApiBaseUrl(), !baseUrl.isEmpty else { return nil }
guard let userProfile = readUserProfileDict() else { return nil }
let lang = resolveLang()
let urlStr = joinUrl(base: baseUrl, path: "/v1/reco/widget")
guard let url = URL(string: urlStr) else { return nil }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.timeoutInterval = 12
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(lang, forHTTPHeaderField: "Accept-Language")
let body: [String: Any] = [
"k": 1,
"user_profile": userProfile,
"already_recommended_ids": [],
"touched_or_viewed_ids": []
]
req.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
do {
let (data, res) = try await URLSession.shared.data(for: req)
guard let httpRes = res as? HTTPURLResponse, (200..<300).contains(httpRes.statusCode) else { return nil }
let obj = try JSONSerialization.jsonObject(with: data, options: [])
guard let root = obj as? [String: Any] else { return nil }
guard let items = root["items"] as? [[String: Any]], let first = items.first else { return nil }
guard let text = first["text"] as? String, !text.isEmpty else { return nil }
let contentId = (first["content_id"] as? Int) ?? Int((first["content_id"] as? NSNumber)?.intValue ?? -1)
if contentId < 0 { return nil }
let meta = root["meta"] as? [String: Any]
return (lang: lang, text: text, contentId: contentId, meta: meta)
} catch {
return nil
}
}
struct EmotionProvider: TimelineProvider { struct EmotionProvider: TimelineProvider {
func placeholder(in context: Context) -> EmotionEntry { func placeholder(in context: Context) -> EmotionEntry {
EmotionEntry(date: Date()) let lang = resolveLang()
return EmotionEntry(
date: Date(),
lang: lang,
title: resolveTitle(lang: lang),
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
footerHint: resolveFooterHint(lang: lang)
)
} }
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) { func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
completion(EmotionEntry(date: Date())) completion(placeholder(in: context))
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) { func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
// V1 Task {
let entry = EmotionEntry(date: Date()) let lang = resolveLang()
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) let today = localDayKey(Date())
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate))) // 1)
if let cached = readCachedText(), cached.dayKey == today {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
title: resolveTitle(lang: cached.lang),
text: cached.text,
footerHint: resolveFooterHint(lang: cached.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
// 2) /
if let fetched = await fetchDailyRecoFromServer() {
saveDailyReco(lang: fetched.lang, dayKey: today, contentId: fetched.contentId, text: fetched.text, meta: fetched.meta)
let entry = EmotionEntry(
date: Date(),
lang: fetched.lang,
title: resolveTitle(lang: fetched.lang),
text: fetched.text,
footerHint: resolveFooterHint(lang: fetched.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
// 3)
if let cached = readCachedText() {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
title: resolveTitle(lang: cached.lang),
text: cached.text,
footerHint: resolveFooterHint(lang: cached.lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
return
}
let entry = EmotionEntry(
date: Date(),
lang: lang,
title: resolveTitle(lang: lang),
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
footerHint: resolveFooterHint(lang: lang)
)
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
}
} }
} }
struct EmotionEntry: TimelineEntry { struct EmotionEntry: TimelineEntry {
let date: Date let date: Date
let lang: String
let title: String
let text: String
let footerHint: String
} }
struct EmotionWidgetView: View { struct EmotionWidgetView: View {
var entry: EmotionProvider.Entry var entry: EmotionProvider.Entry
@Environment(\.widgetFamily) var family @Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
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 {
// //
Text(entry.text)
.font(fontForFamily())
.foregroundColor(widgetTextColor)
.multilineTextAlignment(.leading)
.lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetSolidBackground(widgetBackgroundColor)
.widgetURL(deepLink)
}
private func fontForFamily() -> Font {
switch family { switch family {
case .systemSmall: case .systemSmall:
smallView() return .system(size: 16, weight: .semibold)
case .systemMedium: case .systemMedium:
mediumView() return .system(size: 18, weight: .semibold)
case .systemLarge: case .systemLarge:
largeView() return .system(size: 22, weight: .semibold)
default: default:
smallView() return .system(size: 16, weight: .semibold)
} }
} }
// iOS 15 private func lineSpacingForFamily() -> CGFloat {
private func cardBackground(colors: [Color]) -> some View { switch family {
ZStack { case .systemLarge:
LinearGradient( return 4
colors: colors, default:
startPoint: .topLeading, return 3
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 chip(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.cornerRadius(999)
}
private func smallView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.13, green: 0.16, blue: 0.22),
])
VStack(alignment: .leading, spacing: 10) {
HStack {
chip(title)
Spacer(minLength: 0)
}
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(2)
.lineLimit(4)
Spacer(minLength: 0)
Text("点我回到 App")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.white.opacity(0.65))
}
.padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.09, green: 0.11, blue: 0.17),
])
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
chip(title)
Text(text)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(3)
.lineLimit(5)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
}
//
VStack(alignment: .trailing, spacing: 8) {
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.8))
Spacer(minLength: 0)
Text("今日")
.font(.system(size: 28, weight: .bold))
.foregroundColor(Color.white.opacity(0.12))
} }
} }
.padding(16)
private func lineLimitForFamily() -> Int {
switch family {
case .systemSmall:
return 5
case .systemMedium:
return 6
case .systemLarge:
return 8
default:
return 5
} }
.widgetURL(deepLink)
} }
private func largeView() -> some View { private func paddingForFamily() -> CGFloat {
ZStack { switch family {
cardBackground(colors: [ case .systemSmall:
Color(red: 0.06, green: 0.08, blue: 0.12), return 14
Color(red: 0.14, green: 0.18, blue: 0.28), case .systemMedium:
]) return 16
case .systemLarge:
VStack(alignment: .leading, spacing: 14) { return 18
HStack { default:
chip(title) return 14
Spacer(minLength: 0)
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.78))
}
Text(text)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(4)
.lineLimit(8)
Spacer(minLength: 0)
HStack {
Text("点我回到 Home")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
Spacer(minLength: 0)
Text("🌿")
.font(.system(size: 18))
.opacity(0.9)
} }
} }
.padding(18) }
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()
} }
.widgetURL(deepLink) }
}
private extension View {
func widgetSolidBackground(_ color: Color) -> some View {
modifier(WidgetSolidBackgroundModifier(color: color))
} }
} }

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damer.mindfulness</string>
</array>
</dict>
</plist>

1628
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,14 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"start:clean": "expo start -c",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios --scheme \"Hey Mama\"",
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Hey Mama\"",
"web": "expo start --web", "web": "expo start --web",
"test": "vitest run" "test": "vitest run",
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
"clean:ios-build": "rm -rf ~/Library/Developer/Xcode/DerivedData/client-* 2>/dev/null; echo 'Cleared Xcode DerivedData for client'"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
@@ -15,7 +19,9 @@
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"expo": "~54.0.32", "expo": "~54.0.32",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.20", "expo-dev-client": "^6.0.20",
"expo-device": "^8.0.10",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-linear-gradient": "^15.0.8", "expo-linear-gradient": "^15.0.8",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",

32
client/pnpm-lock.yaml generated
View File

@@ -23,9 +23,15 @@ importers:
expo-constants: expo-constants:
specifier: ~18.0.13 specifier: ~18.0.13
version: 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)) version: 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))
expo-crypto:
specifier: ^15.0.8
version: 15.0.8(expo@54.0.32)
expo-dev-client: expo-dev-client:
specifier: ^6.0.20 specifier: ^6.0.20
version: 6.0.20(expo@54.0.32) version: 6.0.20(expo@54.0.32)
expo-device:
specifier: ^8.0.10
version: 8.0.10(expo@54.0.32)
expo-font: expo-font:
specifier: ~14.0.11 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) 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)
@@ -2229,6 +2235,11 @@ packages:
expo: '*' expo: '*'
react-native: '*' react-native: '*'
expo-crypto@15.0.8:
resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==}
peerDependencies:
expo: '*'
expo-dev-client@6.0.20: expo-dev-client@6.0.20:
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==} resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
peerDependencies: peerDependencies:
@@ -2249,6 +2260,11 @@ packages:
peerDependencies: peerDependencies:
expo: '*' expo: '*'
expo-device@8.0.10:
resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
peerDependencies:
expo: '*'
expo-file-system@19.0.21: expo-file-system@19.0.21:
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==} resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
peerDependencies: peerDependencies:
@@ -3811,6 +3827,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
ua-parser-js@0.7.41:
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
hasBin: true
ua-parser-js@1.0.41: ua-parser-js@1.0.41:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true hasBin: true
@@ -6537,6 +6557,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
expo-crypto@15.0.8(expo@54.0.32):
dependencies:
base64-js: 1.5.1
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)
expo-dev-client@6.0.20(expo@54.0.32): expo-dev-client@6.0.20(expo@54.0.32):
dependencies: 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) 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)
@@ -6566,6 +6591,11 @@ 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) 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)
expo-dev-menu-interface: 2.0.0(expo@54.0.32) expo-dev-menu-interface: 2.0.0(expo@54.0.32)
expo-device@8.0.10(expo@54.0.32):
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)
ua-parser-js: 0.7.41
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)): 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)):
dependencies: 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) 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)
@@ -8282,6 +8312,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
ua-parser-js@0.7.41: {}
ua-parser-js@1.0.41: {} ua-parser-js@1.0.41: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}

View File

@@ -22,7 +22,14 @@ function getOptionalEnv(name: string, fallback: string): string {
export type AppRuntimeEnv = 'local' | 'dev' | 'prod'; export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local'; /**
* Release/TestFlight 场景下如果未注入 EXPO_PUBLIC_ENV
* 默认回退到 prod避免误打到 localhost 导致真机“无法发起网络请求”)。
*/
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
function getApiBaseUrl(env: AppRuntimeEnv): string { function getApiBaseUrl(env: AppRuntimeEnv): string {
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL则优先使用不再强制要求 *_DEV/_PROD // 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL则优先使用不再强制要求 *_DEV/_PROD
@@ -45,8 +52,10 @@ export const API_BASE_URL = getApiBaseUrl(APP_ENV);
* 调试:打印环境变量注入结果(仅开发环境) * 调试:打印环境变量注入结果(仅开发环境)
* *
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。 * 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
*
* 注意:在某些测试环境(如 vitest里 `__DEV__` 可能不存在,需做兼容判断。
*/ */
if (__DEV__) { if (typeof __DEV__ !== 'undefined' && __DEV__) {
const injected = { const injected = {
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV, EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL, EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL,

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import type { UserProfileScoring } from '@/src/storage/appStorage';
import { advanceSuixinState, buildInitialSuixinState, computeSuixinSolidColor, pickSuixinBaseThemeId } from '../index';
import { computeTFromStep } from '../progress';
import { lerpHex } from '../colorMath';
function buildProfile(partial?: Partial<UserProfileScoring>): UserProfileScoring {
const base: UserProfileScoring = {
profile_version: 'v1.2',
profile_source: 'questionnaire',
profile_generated_at: '2026-01-30T00:00:00Z',
profile_confidence: 1.0,
profile_answered: { stage: true, emotion: true, context: true, need: true },
stage: { expecting: 1, parenting: 0, unknown: 0 },
emotion_score: 0.6,
context: {},
need: {},
rule_hits: [],
hard_rules: { forbidden_risk_flags: [], forbidden_content_predicates: [] },
};
return { ...base, ...(partial ?? {}) };
}
describe('suixinTheme', () => {
it('pickSuixinBaseThemeId: stage.unknown=1 → neutral', () => {
const p = buildProfile({ stage: { unknown: 1 } as any, need: { rest_balance: 1 } });
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
});
it('pickSuixinBaseThemeId: need 为空 → neutral', () => {
const p = buildProfile({ need: {} });
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
});
it('pickSuixinBaseThemeId: need 命中 → 对应 base theme', () => {
const p = buildProfile({ need: { rest_balance: 1 } });
expect(pickSuixinBaseThemeId(p)).toBe('rest_balance');
});
it('computeTFromStep: 始终在 [0,1] 且往返不突跳', () => {
const ts = Array.from({ length: 80 }).map((_, i) => computeTFromStep({ stepIndex: i, segments: 12, seed: 'boot' }));
for (const t of ts) {
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(1);
}
// 往返波形:起点与一个周期后的 t 相同
expect(computeTFromStep({ stepIndex: 0, segments: 12, seed: 'boot' })).toBe(
computeTFromStep({ stepIndex: 22, segments: 12, seed: 'boot' }) // 2*(N-1)=22
);
});
it('lerpHex: t=0/1 输出边界色', () => {
expect(lerpHex('#000000', '#FFFFFF', 0)).toBe('#000000');
expect(lerpHex('#000000', '#FFFFFF', 1)).toBe('#FFFFFF');
});
it('buildInitialSuixinState: 生成可用状态并可推进', () => {
const p = buildProfile({ need: { emotional_support: 1 } });
const init = buildInitialSuixinState({ bootId: 'boot-1', profile: p, now: new Date('2026-02-05T00:00:00Z') });
expect(init.schema_version).toBe(1);
expect(init.base_theme_id).toBe('emotional_support');
expect(init.boot_id).toBe('boot-1');
expect(init.last_color).toMatch(/^#[0-9A-F]{6}$/);
const next = advanceSuixinState(init, new Date('2026-02-05T00:00:01Z'));
expect(next.step_index).toBe(1);
expect(next.base_theme_id).toBe(init.base_theme_id);
expect(next.last_color).toMatch(/^#[0-9A-F]{6}$/);
});
it('computeSuixinSolidColor: 输出为合法 hex', () => {
const c = computeSuixinSolidColor({ baseThemeId: 'neutral', stepIndex: 3, seed: 'boot' });
expect(c).toMatch(/^#[0-9A-F]{6}$/);
});
});

View File

@@ -0,0 +1,53 @@
function clamp01(t: number): number {
if (!Number.isFinite(t)) return 0;
return Math.min(1, Math.max(0, t));
}
type Rgb = { r: number; g: number; b: number };
function toByte(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.min(255, Math.max(0, Math.round(v)));
}
export function hexToRgb(hex: string): Rgb | null {
const h = String(hex || '').trim();
const m = /^#?([0-9a-fA-F]{6})$/.exec(h);
if (!m) return null;
const raw = m[1];
const n = parseInt(raw, 16);
// eslint-disable-next-line no-bitwise
const r = (n >> 16) & 0xff;
// eslint-disable-next-line no-bitwise
const g = (n >> 8) & 0xff;
// eslint-disable-next-line no-bitwise
const b = n & 0xff;
return { r, g, b };
}
export function rgbToHex(rgb: Rgb): string {
const r = toByte(rgb.r).toString(16).padStart(2, '0');
const g = toByte(rgb.g).toString(16).padStart(2, '0');
const b = toByte(rgb.b).toString(16).padStart(2, '0');
return `#${r}${g}${b}`.toUpperCase();
}
/**
* 仅允许线性插值Hard Rule
*/
export function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
const tt = clamp01(t);
return {
r: a.r + (b.r - a.r) * tt,
g: a.g + (b.g - a.g) * tt,
b: a.b + (b.b - a.b) * tt,
};
}
export function lerpHex(topHex: string, bottomHex: string, t: number, fallbackHex = '#E8F1EC'): string {
const top = hexToRgb(topHex);
const bottom = hexToRgb(bottomHex);
if (!top || !bottom) return fallbackHex;
return rgbToHex(lerpRgb(top, bottom, t));
}

View File

@@ -0,0 +1,65 @@
import type { SuixinBaseThemeId, SuixinThemeStateV1, UserProfileScoring } from '@/src/storage/appStorage';
import { getThemeTriplet, NEUTRAL_THEME_COLORS } from './palette';
import { lerpHex } from './colorMath';
import { computeTFromStep } from './progress';
import { pickSuixinBaseThemeId } from './pickTheme';
export { NEUTRAL_THEME_COLORS } from './palette';
export { pickSuixinBaseThemeId } from './pickTheme';
/**
* 根据 base theme 与 step 计算当前背景纯色
*/
export function computeSuixinSolidColor(args: {
baseThemeId: SuixinBaseThemeId;
stepIndex: number;
seed: string;
}): string {
const [top, _mid, bottom] = getThemeTriplet(args.baseThemeId);
const t = computeTFromStep({ stepIndex: args.stepIndex, segments: 12, seed: args.seed });
return lerpHex(top, bottom, t, NEUTRAL_THEME_COLORS[1]);
}
/**
* 初始化一份随心状态(在冷启动会话内锁定 base theme
*/
export function buildInitialSuixinState(args: {
bootId: string;
profile: UserProfileScoring | null;
now?: Date;
}): SuixinThemeStateV1 {
const now = args.now ?? new Date();
const baseThemeId = pickSuixinBaseThemeId(args.profile);
const seed = args.bootId;
const step_index = 0;
const last_color = computeSuixinSolidColor({ baseThemeId, stepIndex: step_index, seed });
return {
schema_version: 1,
saved_at: now.toISOString(),
boot_id: args.bootId,
base_theme_id: baseThemeId,
seed,
step_index,
last_color,
};
}
/**
* 切换文案时推进一步(不跨主题)
*/
export function advanceSuixinState(prev: SuixinThemeStateV1, now?: Date): SuixinThemeStateV1 {
const nextStep = (Number.isFinite(prev.step_index) ? prev.step_index : 0) + 1;
const last_color = computeSuixinSolidColor({
baseThemeId: prev.base_theme_id,
stepIndex: nextStep,
seed: prev.seed,
});
return {
...prev,
saved_at: (now ?? new Date()).toISOString(),
step_index: nextStep,
last_color,
};
}

View File

@@ -0,0 +1,20 @@
import type { SuixinBaseThemeId } from '@/src/storage/appStorage';
/**
* 随心主题色盘(与设计说明文档保持一致)
*/
export const BASE_THEME_COLORS: Record<Exclude<SuixinBaseThemeId, 'neutral'>, [string, string, string]> = {
emotional_support: ['#F6DCE4', '#FFEFF4', '#FFF7FA'],
parenting_pressure: ['#D6EAF5', '#EEF6FB', '#F8FCFF'],
self_worth: ['#FFD8A8', '#FFE8C9', '#FFF6E5'],
anxiety_relief: ['#DFF3EA', '#ECFBF6', '#F6FFFB'],
rest_balance: ['#F2E6D8', '#FAF3EC', '#FFFDF9'],
};
export const NEUTRAL_THEME_COLORS: [string, string, string] = ['#F4F7F2', '#E8F1EC', '#EDF4F8'];
export function getThemeTriplet(themeId: SuixinBaseThemeId): [string, string, string] {
if (themeId === 'neutral') return NEUTRAL_THEME_COLORS;
return BASE_THEME_COLORS[themeId];
}

View File

@@ -0,0 +1,32 @@
import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage';
const ALL_NEED_THEME_IDS: ReadonlySet<string> = new Set([
'emotional_support',
'parenting_pressure',
'self_worth',
'anxiety_relief',
'rest_balance',
]);
/**
* Base Theme 选择Theme Picking
*
* Hard Rules对齐设计文档
* - mom_stage = unknown → 强制 Neutral
* - need 跳过/缺失 → Neutral
*/
export function pickSuixinBaseThemeId(profile: UserProfileScoring | null | undefined): SuixinBaseThemeId {
if (!profile) return 'neutral';
// Hard Ruleunknown → Neutral
if (profile.stage?.unknown === 1) return 'neutral';
const needObj = profile.need ?? {};
const keys = Object.keys(needObj);
if (keys.length === 0) return 'neutral';
const needId = keys[0];
if (!ALL_NEED_THEME_IDS.has(needId)) return 'neutral';
return needId as Exclude<SuixinBaseThemeId, 'neutral'>;
}

View File

@@ -0,0 +1,40 @@
function clampInt(n: number, min: number, max: number): number {
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, Math.floor(n)));
}
function hashStringToInt32(input: string): number {
// 简单可复现 hash用于把 seed 映射为偏移量(不用于安全场景)
let h = 0;
for (let i = 0; i < input.length; i += 1) {
// eslint-disable-next-line no-bitwise
h = (h * 31 + input.charCodeAt(i)) | 0;
}
return h;
}
/**
* 生成 t ∈ [0,1],用于 Color(t)=lerp(top,bottom,t)
*
* 说明:
* - Home 没有 scroll用“切换文案 step_index”模拟连续流动
* - 采用往返波形,避免从 1 回到 0 的突跳
*/
export function computeTFromStep(args: {
stepIndex: number;
segments?: number; // N默认 12
seed?: string; // 允许用 seed 做初始相位偏移(同一次冷启动内稳定)
}): number {
const N = clampInt(args.segments ?? 12, 3, 60);
const stepIndex = clampInt(args.stepIndex, 0, 1_000_000_000);
const period = 2 * (N - 1);
const seed = String(args.seed ?? '');
const offset = seed ? Math.abs(hashStringToInt32(seed)) % period : 0;
const phase = (stepIndex + offset) % period;
const up = phase <= (N - 1);
const pos = up ? phase : period - phase;
return pos / (N - 1);
}

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

@@ -1,22 +1,25 @@
## 用文案表(请在此文件对应的 JSON 中修改) ## 用文案表(請在對應 JSON 中修改)
**单一文案源文件**`client/src/i18n/locales/all.json` ### 語言與檔案對應(單一來源,避免多檔覆蓋)
- **English**`all.json``en` | 語言 | 實際使用的檔案 | 說明 |
- **繁体中文**`all.json``zh-TW` |------------|-----------------------------|------|
| **英文** | `locales/all.json``en` | 只改 all.json 的 `en` 區塊 |
| **繁體中文** | `locales/zh-TW.json` | **唯一來源**Onboarding / 開屏 consent 等繁中文案只改此檔 |
> 说明:项目运行时只读取 `all.json`;请不要再改 `locales/en.json`、`locales/zh-TW.json`(它们已不再作为运行时数据源) - 運行時 **繁中zh-TW只讀取 `zh-TW.json`**,不會讀取 `all.json` 的 zh-TW 區塊
- 修改 JSON 後需**重新載入 App**(模擬器 `Cmd+R`)或重啟 Metro必要時加 `--clear`)才會看到新文案。
- **若修改 zh-TW.json 後開屏 consent 仍顯示舊文案**:請執行 `npm run clean:cache`,再以 `npm run start:clean` 啟動 Metro或先刪除模擬器上的 App 再執行 `npm run ios:clean`),確保吃到最新 bundle。
### 快速索引(高文案) ### 快速索引(高文案)
- **開屏 / 協議**`consent.*`**繁中只改 zh-TW.json**`consent.title``consent.subtitle``consent.subtitleSecondary`、agree, privacy, terms, notice…
- **Onboarding 問卷**`onboardingSurvey.steps.*`name.title, status.title, status.options.* …)
- **Onboarding 招呼語**`onboardingSurvey.greeting`Hi {{name}}
- **Home**`home.*` - **Home**`home.*`
- **Push 提示**`push.*` - **Push 提示**`push.*`
- **主**`theme.*` - **主**`theme.*`
- **我的/Profile**`profile.*` - **我的 / Profile**`profile.*`
- **收藏**`favorites.*` - **收藏**`favorites.*`
- **设置**`settings.*` - **設定**`settings.*`
- **Onboarding问卷**`onboardingSurvey.steps.*`
- **Onboarding兴趣**`intent.*`
- **Mock 文案**`mock.*` - **Mock 文案**`mock.*`

View File

@@ -3,9 +3,15 @@ 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 配置差异导致无法编译
// 繁中zh-TW唯一來源locales/zh-TW.json修改 Onboarding/開屏等繁中文案請只改該檔案
// 本 App 未載入 zh-CN.json若看到類似「你本就完美」「一切都会变好」等簡中 consent 文案,為舊 bundle 快取導致,請執行 clean:cache + start:clean 或卸載 App 重裝。
// 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> };
// eslint-disable-next-line @typescript-eslint/no-var-requires
const zhTW = require('./locales/zh-TW.json') as Record<string, unknown>;
/** /**
* 语言码约定: * 语言码约定:
@@ -29,13 +35,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;
} }
@@ -84,7 +85,8 @@ export async function initI18n(): Promise<void> {
await i18n.use(initReactI18next).init({ await i18n.use(initReactI18next).init({
resources: { resources: {
'zh-TW': { translation: all['zh-TW'] as any }, // 繁中唯一來源zh-TW.jsonall.json 的 zh-TW 區塊不會被載入)
'zh-TW': { translation: zhTW },
en: { translation: all.en as any }, en: { translation: all.en as any },
}, },
lng: initialLang, lng: initialLang,
@@ -94,6 +96,18 @@ export async function initI18n(): Promise<void> {
escapeValue: false, escapeValue: false,
}, },
}); });
// 臨時 debug確認實際使用的 language 與 consent.title 值(方便驗證繁中來自 zh-TW.json
if (typeof __DEV__ !== 'undefined' && __DEV__) {
const consentTitle = i18n.t('consent.title');
console.log(
'[i18n] 已初始化 language=',
i18n.language,
'| consent.title=',
consentTitle,
'| 繁中來源=zh-TW.json英文來源=all.json 的 en 區塊'
);
}
} }
/** /**

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

@@ -4,6 +4,7 @@
"ok": "OK", "ok": "OK",
"cancel": "Cancel", "cancel": "Cancel",
"error": "Error", "error": "Error",
"notice": "Notice",
"openLinkError": "Cannot open link", "openLinkError": "Cannot open link",
"back": "Back", "back": "Back",
"close": "Close" "close": "Close"
@@ -13,7 +14,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Next", "next": "Next",
"skip": "Skip", "skip": "Skip",
"skipAll": "Skip onboarding", "skipAll": "Skip",
"q1Title": "How are you feeling lately?", "q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.", "q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?", "q2Title": "What kind of support do you want?",
@@ -24,46 +25,49 @@
"q4Desc": "You can skip. Well stay with you along the way." "q4Desc": "You can skip. Well stay with you along the way."
}, },
"onboardingSurvey": { "onboardingSurvey": {
"greeting": "Hi {{name}},",
"steps": { "steps": {
"name": { "title": "What should I call you?" }, "name": { "title": "What should we call you?", "placeholder": "Mama" },
"status": { "status": {
"title": "Your current stage?", "title": "Where are you at right now?",
"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"
} }
}, },
"reminder": { "title": "How many reminders do you want per day?" } "reminder": { "title": "How often would you like a gentle reminder?" }
} }
}, },
"intent": { "intent": {
@@ -95,7 +99,8 @@
"theme": { "theme": {
"title": "Theme", "title": "Theme",
"scenery": "Scenery", "scenery": "Scenery",
"color": "Color" "color": "Color",
"suixin": "Ease"
}, },
"profile": { "profile": {
"title": "Me", "title": "Me",
@@ -111,6 +116,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Daily Reminder", "title": "Daily Reminder",
"timesUnit": "times", "timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder", "pushLabel": "Push Reminder",
"ok": "Ok", "ok": "Ok",
"minus": "Decrease", "minus": "Decrease",
@@ -119,6 +125,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."
}, },
@@ -135,11 +144,17 @@
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like." "widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
}, },
"consent": { "consent": {
"title": "You Are Perfect.", "title": "Hey mama.",
"subtitle": "Everything Will Be Better.", "subtitle": "Youre doing okay\nright now.",
"subtitleSecondary": "",
"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.",
"noticeRich": "By continuing,\nyou agree to the <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
"linkUnavailable": "Failed to load the policy link. Please check your network and try again.",
"linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}",
"linkLoadingSuffix": " (loading…)"
}, },
"permissions": { "permissions": {
"notificationsDenied": "Notifications are denied. Please enable them in Settings." "notificationsDenied": "Notifications are denied. Please enable them in Settings."
@@ -161,6 +176,9 @@
"ok": "確定", "ok": "確定",
"cancel": "取消", "cancel": "取消",
"back": "返回", "back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉" "close": "關閉"
}, },
"onboarding": { "onboarding": {
@@ -168,7 +186,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "下一步", "next": "下一步",
"skip": "跳過", "skip": "跳過",
"skipAll": "跳過整個引導", "skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?", "q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。", "q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?", "q2Title": "你更希望獲得哪種支持?",
@@ -179,21 +197,24 @@
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。" "q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
}, },
"onboardingSurvey": { "onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": { "steps": {
"name": { "title": "我可以怎麼稱呼你" }, "name": { "title": "怎麼稱呼你呢?", "placeholder": "媽媽" },
"status": { "status": {
"title": "媽媽的狀態", "title": "你現在正處在哪個階段呢",
"options": { "options": {
"pregnant": "懷孕中/準備成為媽媽", "pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子", "has_kids": "已經有孩子",
"no_fill": "不想填寫" "no_fill": "我暫時不想說"
} }
}, },
"emotion": { "emotion": {
"title": "當下情緒狀態", "title": "今天的你,還好嗎",
"options": { "options": {
"happy": "愉悅、滿足", "happy": "愉悅、滿足",
"calm": "平靜、安穩", "calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣", "stressed": "被壓得有點喘不過氣",
"low": "情緒低落" "low": "情緒低落"
} }
@@ -250,7 +271,8 @@
"theme": { "theme": {
"title": "主題", "title": "主題",
"scenery": "風景", "scenery": "風景",
"color": "顏色" "color": "顏色",
"suixin": "隨心"
}, },
"profile": { "profile": {
"title": "我的", "title": "我的",
@@ -266,6 +288,7 @@
"dailyReminder": { "dailyReminder": {
"title": "每日提醒", "title": "每日提醒",
"timesUnit": "次", "timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒", "pushLabel": "推送提醒",
"ok": "確定", "ok": "確定",
"minus": "減少次數", "minus": "減少次數",
@@ -274,6 +297,9 @@
"widget": { "widget": {
"lockScreen": "鎖屏小工具", "lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具", "homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一", "previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
}, },
@@ -290,9 +316,17 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
}, },
"consent": { "consent": {
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"agree": "同意並繼續", "agree": "同意並繼續",
"privacy": "隱私協議", "privacy": "隱私協議",
"terms": "用戶使用協議" "terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
"linkLoadingSuffix": "(載入中…)"
}, },
"permissions": { "permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"

View File

@@ -11,7 +11,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Next", "next": "Next",
"skip": "Skip", "skip": "Skip",
"skipAll": "Skip onboarding", "skipAll": "Skip",
"q1Title": "How are you feeling lately?", "q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.", "q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?", "q2Title": "What kind of support do you want?",
@@ -59,6 +59,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Daily Reminder", "title": "Daily Reminder",
"timesUnit": "times", "timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder", "pushLabel": "Push Reminder",
"ok": "Ok", "ok": "Ok",
"minus": "Decrease", "minus": "Decrease",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Siguiente", "next": "Siguiente",
"skip": "Saltar", "skip": "Saltar",
"skipAll": "Saltar introducción", "skipAll": "Saltar",
"q1Title": "¿Cómo te sientes últimamente?", "q1Title": "¿Cómo te sientes últimamente?",
"q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.", "q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.",
"q2Title": "¿Qué tipo de apoyo quieres?", "q2Title": "¿Qué tipo de apoyo quieres?",
@@ -57,6 +57,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Recordatorio diario", "title": "Recordatorio diario",
"timesUnit": "veces", "timesUnit": "veces",
"timesUnitSingular": "vez",
"pushLabel": "Recordatorio Push", "pushLabel": "Recordatorio Push",
"ok": "Ok", "ok": "Ok",
"minus": "Disminuir", "minus": "Disminuir",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "Próximo", "next": "Próximo",
"skip": "Pular", "skip": "Pular",
"skipAll": "Pular introdução", "skipAll": "Pular",
"q1Title": "Como você tem se sentido ultimamente?", "q1Title": "Como você tem se sentido ultimamente?",
"q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.", "q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.",
"q2Title": "Que tipo de apoio você quer?", "q2Title": "Que tipo de apoio você quer?",
@@ -57,6 +57,7 @@
"dailyReminder": { "dailyReminder": {
"title": "Lembrete diário", "title": "Lembrete diário",
"timesUnit": "vezes", "timesUnit": "vezes",
"timesUnitSingular": "vez",
"pushLabel": "Lembrete Push", "pushLabel": "Lembrete Push",
"ok": "Ok", "ok": "Ok",
"minus": "Diminuir", "minus": "Diminuir",

View File

@@ -12,7 +12,7 @@
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "下一步", "next": "下一步",
"skip": "跳过", "skip": "跳过",
"skipAll": "跳过整个引导", "skipAll": "跳过",
"q1Title": "你最近的感受更接近哪一种?", "q1Title": "你最近的感受更接近哪一种?",
"q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。", "q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。",
"q2Title": "你更希望获得哪种支持?", "q2Title": "你更希望获得哪种支持?",
@@ -30,7 +30,7 @@
"later": "稍后", "later": "稍后",
"loading": "处理中…", "loading": "处理中…",
"errorTitle": "提示", "errorTitle": "提示",
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。" "errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token建议用真机测试。"
}, },
"home": { "home": {
"title": "正念", "title": "正念",
@@ -60,6 +60,7 @@
"dailyReminder": { "dailyReminder": {
"title": "每日提醒", "title": "每日提醒",
"timesUnit": "次", "timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒", "pushLabel": "推送提醒",
"ok": "确定", "ok": "确定",
"minus": "减少次数", "minus": "减少次数",

View File

@@ -2,14 +2,18 @@
"common": { "common": {
"ok": "確定", "ok": "確定",
"cancel": "取消", "cancel": "取消",
"back": "返回" "back": "返回",
"error": "錯誤",
"notice": "提示",
"openLinkError": "無法打開鏈接",
"close": "關閉"
}, },
"onboarding": { "onboarding": {
"title": "歡迎", "title": "歡迎",
"progress": "{{current}}/{{total}}", "progress": "{{current}}/{{total}}",
"next": "下一步", "next": "下一步",
"skip": "跳過", "skip": "跳過",
"skipAll": "跳過整個引導", "skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?", "q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。", "q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?", "q2Title": "你更希望獲得哪種支持?",
@@ -19,6 +23,64 @@
"q4Title": "給自己一句溫柔的話", "q4Title": "給自己一句溫柔的話",
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。" "q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
}, },
"onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": {
"name": {
"title": "怎麼稱呼你呢?",
"placeholder": "媽媽"
},
"status": {
"title": "你現在正處在哪個階段呢?",
"options": {
"pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子",
"no_fill": "我暫時不想說"
}
},
"emotion": {
"title": "今天的你,還好嗎?",
"options": {
"happy": "愉悅、滿足",
"calm": "平靜、安穩",
"okay": "還可以、普通",
"tired": "疲累、沒什麼力氣",
"stressed": "被壓得有點喘不過氣",
"low": "情緒低落"
}
},
"influence": {
"title": "是什麼影響了你最近的感受?",
"options": {
"family": "家庭與孩子",
"work": "工作或學習",
"relationship": "親密關係",
"friends": "朋友與人際",
"health": "身心健康"
}
},
"support": {
"title": "最需要什麼支持?",
"options": {
"emotional": "情緒支持",
"parenting": "育兒壓力",
"self_worth": "自我價值",
"anxiety": "焦慮舒緩",
"balance": "休息與平衡"
}
},
"reminder": {
"title": "你希望一天收到幾次肯定語?"
}
}
},
"intent": {
"title": "你希望得到什麼幫助?",
"love": "愛情",
"life": "生活",
"travel": "旅遊",
"work": "職場"
},
"push": { "push": {
"title": "通知", "title": "通知",
"cardTitle": "開啟溫柔提醒", "cardTitle": "開啟溫柔提醒",
@@ -41,7 +103,8 @@
"theme": { "theme": {
"title": "主題", "title": "主題",
"scenery": "風景", "scenery": "風景",
"color": "顏色" "color": "顏色",
"suixin": "隨心"
}, },
"profile": { "profile": {
"title": "我的", "title": "我的",
@@ -57,6 +120,7 @@
"dailyReminder": { "dailyReminder": {
"title": "每日提醒", "title": "每日提醒",
"timesUnit": "次", "timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒", "pushLabel": "推送提醒",
"ok": "確定", "ok": "確定",
"minus": "減少次數", "minus": "減少次數",
@@ -65,12 +129,16 @@
"widget": { "widget": {
"lockScreen": "鎖屏小工具", "lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具", "homeScreen": "桌面小工具",
"howToTitle": "如何添加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一", "previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
}, },
"favorites": { "favorites": {
"title": "收藏夾", "title": "收藏夾",
"empty": "這裡還沒有收藏內容。" "empty": "這裡還沒有收藏內容。",
"unknownText": "這條文案暫時無法顯示。"
}, },
"settings": { "settings": {
"title": "設定", "title": "設定",
@@ -80,9 +148,30 @@
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
}, },
"consent": { "consent": {
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"agree": "同意並繼續", "agree": "同意並繼續",
"privacy": "隱私協議", "privacy": "隱私協議",
"terms": "用戶使用協議" "terms": "用戶使用協議",
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
"linkLoadingSuffix": "(載入中…)"
},
"permissions": {
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
},
"language": {
"zhTW": "繁體中文",
"en": "English"
},
"mock": {
"c1": "你已經很努力了,今天也值得被溫柔對待。",
"c2": "深呼吸三次,把注意力帶回當下。",
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
"c4": "你不需要完美,你已經足夠好。",
"c5": "把手放在心口,對自己說一句:辛苦了。"
} }
} }

View File

@@ -0,0 +1,174 @@
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';
import { getLocalDayKey } from '@/src/utils/date';
import {
appGroupGetString,
appGroupReloadAllTimelines,
appGroupSetString,
isAppGroupStorageAvailable,
} from '@/src/services/appGroupStorage';
/**
* App Group 共享 keyApp ↔ Widget 共通)
*/
export const WIDGET_CONFIG_KEY = 'widget.config.v1';
export const WIDGET_USER_PROFILE_KEY = 'widget.userProfile.v1_2';
export const WIDGET_DAILY_RECO_KEY = 'widget.dailyReco.v1';
export type WidgetConfigV1 = {
schema_version: 1;
saved_at: string; // ISO8601
apiBaseUrl: string;
};
export type WidgetUserProfileV1_2 = {
schema_version: 1;
saved_at: string; // ISO8601
user_profile: UserProfileV1_2;
};
export type WidgetDailyRecoV1 = {
schema_version: 1;
saved_at: string; // ISO8601
day_key: string; // YYYY-MM-DD用户时区
lang: 'en' | 'tc';
item: null | {
content_id: number;
text: string;
final_score?: number;
fallback_level_final?: number;
};
meta?: Record<string, unknown>;
source?: 'app' | 'widget';
};
function safeJsonParse<T>(raw: string | null): T | null {
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
return {
profile_version: scoringProfile.profile_version,
profile_source: scoringProfile.profile_source,
profile_generated_at: scoringProfile.profile_generated_at,
profile_confidence: scoringProfile.profile_confidence,
profile_answered: scoringProfile.profile_answered,
stage: scoringProfile.stage,
emotion_score: scoringProfile.emotion_score,
context: scoringProfile.context,
need: scoringProfile.need,
};
}
export async function syncWidgetConfig(): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
const payload: WidgetConfigV1 = {
schema_version: 1,
saved_at: new Date().toISOString(),
apiBaseUrl: API_BASE_URL,
};
await appGroupSetString(WIDGET_CONFIG_KEY, JSON.stringify(payload));
}
export async function syncWidgetUserProfileFromScoring(scoringProfile: UserProfileV1_2_Extended): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
const payload: WidgetUserProfileV1_2 = {
schema_version: 1,
saved_at: new Date().toISOString(),
user_profile: pickUserProfileV1_2(scoringProfile),
};
await appGroupSetString(WIDGET_USER_PROFILE_KEY, JSON.stringify(payload));
}
export async function syncWidgetUserProfileFromStorage(): Promise<void> {
const scoringProfile = await getUserProfileScoring();
if (!scoringProfile) return;
await syncWidgetUserProfileFromScoring(scoringProfile);
}
export async function getWidgetDailyRecoCache(): Promise<WidgetDailyRecoV1 | null> {
if (!isAppGroupStorageAvailable()) return null;
const raw = await appGroupGetString(WIDGET_DAILY_RECO_KEY);
return safeJsonParse<WidgetDailyRecoV1>(raw);
}
export async function setWidgetDailyRecoCache(cache: WidgetDailyRecoV1): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
await appGroupSetString(WIDGET_DAILY_RECO_KEY, JSON.stringify(cache));
}
/**
* App 前台辅助刷新(不保证准点,但能提升更新及时性与一致性)
*
* 规则:
* - 若共享缓存 `day_key` 已是今天 → 不请求
* - 若缺少用户画像 → 不请求(交给 Widget 走兜底/下次重试)
* - 成功后写入共享缓存并触发 Widget reload系统仍可能延迟
*/
export async function ensureDailyWidgetRecoUpToDate(args?: {
reason?: string;
scoringProfile?: UserProfileV1_2_Extended | null;
}): Promise<void> {
if (!isAppGroupStorageAvailable()) return;
// 先确保 Widget 能拿到 baseURLdev/pro 切换时很关键)
await syncWidgetConfig();
const today = getLocalDayKey(new Date());
const cached = await getWidgetDailyRecoCache();
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
if (!scoringProfile) return;
// 同步画像给 Widget保证 Widget 独立拉取也有输入)
await syncWidgetUserProfileFromScoring(scoringProfile);
try {
const { items, meta } = await fetchRecoWidget({
k: 1,
user_profile: pickUserProfileV1_2(scoringProfile),
already_recommended_ids: [],
touched_or_viewed_ids: [],
});
const top = items?.[0];
if (!top?.text) return;
const lang = toBackendLocaleFromLanguageTag(i18n.language);
await setWidgetDailyRecoCache({
schema_version: 1,
saved_at: new Date().toISOString(),
day_key: today,
// 语言策略:与后端 Accept-Language 保持一致(目前只区分 en/tc
lang,
item: {
content_id: top.content_id,
text: top.text,
final_score: top.final_score,
fallback_level_final: top.fallback_level_final,
},
meta: meta as Record<string, unknown>,
source: 'app',
});
// 触发 Widget 刷新(系统仍可能延迟)
await appGroupReloadAllTimelines();
} catch (e) {
// 失败不阻塞主流程Widget 将使用缓存或兜底文案
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.log('[DailyWidgetReco] App 前台刷新失败:', args?.reason ?? 'unknown', e);
}
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import i18n from 'i18next';
import { buildAcceptLanguage } from '../legalApi';
describe('legalApi.buildAcceptLanguage', () => {
function setLang(lang: string) {
Object.defineProperty(i18n, 'language', {
value: lang,
configurable: true,
});
}
it('非中文语言回退为 en', () => {
setLang('en');
expect(buildAcceptLanguage()).toBe('en');
setLang('es');
expect(buildAcceptLanguage()).toBe('en');
});
it('简中/其他中文不支持时回退为 en', () => {
setLang('zh-CN');
expect(buildAcceptLanguage()).toBe('en');
setLang('zh');
expect(buildAcceptLanguage()).toBe('en');
});
it('繁体中文归一为 tc', () => {
setLang('zh-TW');
expect(buildAcceptLanguage()).toBe('tc');
});
it('显式 tc / hant 等归一为 tc', () => {
setLang('tc');
expect(buildAcceptLanguage()).toBe('tc');
setLang('zh-Hant');
expect(buildAcceptLanguage()).toBe('tc');
});
});

View File

@@ -0,0 +1,53 @@
import { NativeModules, Platform } from 'react-native';
type AppGroupStorageNativeModule = {
/**
* 写入 App Group 的 UserDefaults值为字符串通常是 JSON
*/
setString(key: string, value: string): Promise<void>;
/**
* 读取 App Group 的 UserDefaults值为字符串通常是 JSON
*/
getString(key: string): Promise<string | null>;
/**
* 触发 Widget 刷新iOS 系统仍可能延迟)
*/
reloadAllTimelines(): Promise<void>;
};
function getNativeModule(): AppGroupStorageNativeModule | null {
if (Platform.OS !== 'ios') return null;
const raw = (NativeModules as Record<string, unknown>)?.AppGroupStorage as unknown;
if (!raw || typeof raw !== 'object') return null;
// 注意:若 iOS 原生模块没有正确导出方法(例如缺少 RCT_EXTERN_METHOD 桥接)
// JS 侧可能能拿到模块对象,但方法会是 undefined这里需要做运行时校验避免崩溃。
const m = raw as Partial<AppGroupStorageNativeModule>;
if (typeof m.setString !== 'function') return null;
if (typeof m.getString !== 'function') return null;
if (typeof m.reloadAllTimelines !== 'function') return null;
return m as AppGroupStorageNativeModule;
}
export function isAppGroupStorageAvailable(): boolean {
return Boolean(getNativeModule());
}
export async function appGroupSetString(key: string, value: string): Promise<void> {
const m = getNativeModule();
if (!m) return;
await m.setString(key, value);
}
export async function appGroupGetString(key: string): Promise<string | null> {
const m = getNativeModule();
if (!m) return null;
return await m.getString(key);
}
export async function appGroupReloadAllTimelines(): Promise<void> {
const m = getNativeModule();
if (!m) return;
await m.reloadAllTimelines();
}

View File

@@ -0,0 +1,29 @@
import i18n from 'i18next';
import { httpJson } from '../utils/http';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
export type LegalLinks = {
privacyPolicyUrl: string;
termsOfUseUrl: string;
resolvedLang: 'en' | 'tc';
};
export function buildAcceptLanguage(): 'en' | 'tc' {
// 当前多语言仅支持 EN / TC其他语言统一回退到 en
return toBackendLocaleFromLanguageTag(i18n.language);
}
export async function fetchLegalLinks(): Promise<LegalLinks> {
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
return await httpJson<LegalLinks>({
path: '/v1/legal/links',
method: 'GET',
headers,
timeoutMs: 8_000,
debugLabel: 'LegalLinks',
});
}

View File

@@ -0,0 +1,209 @@
import i18n from 'i18next';
import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
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';
export type PushPlatform = 'ios' | 'android';
export type PushDeviceMeta = {
model?: string;
os_version?: string;
app_version?: string;
locale?: string;
timezone?: string;
};
export type PushRegisterRequest = {
client_user_id: string;
platform: PushPlatform;
push_token: string;
app_id: string;
env: PushEnv;
device_meta?: PushDeviceMeta;
};
export type PushPreferencesRequest = {
client_user_id: string;
enabled: boolean;
times_per_day: number; // 05
timezone?: string;
locale?: string;
// 可选:用户画像(用于后端 Push 场景推荐文案生成)
user_profile?: UserProfileScoring;
};
export type PushPreferencesResponse = PushPreferencesRequest & {
updated_at?: string;
};
export function buildAcceptLanguage(): 'en' | 'tc' {
return toBackendLocaleFromLanguageTag(i18n.language);
}
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {
// 客户端 APP_ENV: local/dev/prod → 后端推送 env: dev/prod
if (appEnv === 'prod') return 'prod';
return 'dev';
}
function pickAppId(): string {
// 优先按平台取 bundleId/package拿不到则回退到 slug
if (Platform.OS === 'ios') {
return (
Constants.expoConfig?.ios?.bundleIdentifier ||
Constants.easConfig?.projectId ||
Constants.expoConfig?.slug ||
'unknown'
);
}
if (Platform.OS === 'android') {
return (
Constants.expoConfig?.android?.package ||
Constants.easConfig?.projectId ||
Constants.expoConfig?.slug ||
'unknown'
);
}
return Constants.expoConfig?.slug || 'unknown';
}
function pickTimezone(): string | undefined {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz && String(tz).trim() ? String(tz).trim() : undefined;
} catch {
return undefined;
}
}
function pickLocale(): string | undefined {
const lang = (i18n.language || '').trim();
return lang ? lang : undefined;
}
function pickPlatform(): PushPlatform {
return Platform.OS === 'ios' ? 'ios' : 'android';
}
function getExpoProjectId(): string | undefined {
// Expo 官方推荐读取 projectIdEAS/Dev Client 下通常需要)
return (
Constants.easConfig?.projectId ||
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
// 兜底:某些运行时环境仍可直接读到 EXPO_PUBLIC_ 注入
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
undefined
);
}
export async function getExpoPushTokenOrThrow(): Promise<string> {
const projectId = getExpoProjectId();
try {
const res = projectId
? await Notifications.getExpoPushTokenAsync({ projectId })
: await Notifications.getExpoPushTokenAsync();
return res.data;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const hint = projectId
? ''
: '(可能缺少 EAS projectId请在 .env.local 配置 EXPO_PUBLIC_EAS_PROJECT_ID或在 app.json/app.config.ts 的 extra.eas.projectId 写入后重试)';
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
}
}
export async function registerPushToken(args: { pushToken: string }): Promise<void> {
const clientUserId = await getOrCreateClientUserId();
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const deviceMeta: PushDeviceMeta = {
os_version: String(Platform.Version ?? ''),
app_version: Constants.expoConfig?.version,
locale: pickLocale(),
timezone: pickTimezone(),
};
const body: PushRegisterRequest = {
client_user_id: clientUserId,
platform: pickPlatform(),
push_token: args.pushToken,
app_id: pickAppId(),
env: toPushEnv(APP_ENV),
device_meta: deviceMeta,
};
await httpJson<void>({
path: '/v1/push/register',
method: 'POST',
headers,
body,
timeoutMs: 10_000,
debugLabel: 'PushRegister',
});
}
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const tz = pickTimezone();
const locale = pickLocale();
const scoringProfile = await getUserProfileScoring().catch(() => null);
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const body: PushPreferencesRequest = {
client_user_id: clientUserId,
enabled: Boolean(args.enabled) && args.timesPerDay > 0,
times_per_day: Math.min(5, Math.max(0, Math.round(args.timesPerDay))),
timezone: tz,
locale,
user_profile: scoringProfile ?? undefined,
};
return await httpJson<PushPreferencesResponse>({
path: '/v1/push/preferences',
method: 'PUT',
headers,
body,
timeoutMs: 10_000,
debugLabel: 'PushPreferences',
});
}
export async function getPushPreferences(): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const headers: Record<string, string> = {
'Accept-Language': buildAcceptLanguage(),
};
const qs = `client_user_id=${encodeURIComponent(clientUserId)}`;
return await httpJson<PushPreferencesResponse>({
path: `/v1/push/preferences?${qs}`,
method: 'GET',
headers,
timeoutMs: 8_000,
debugLabel: 'PushPreferencesGet',
});
}
// 预留:未来推送文案个性化需要上报用户画像时使用(本期先不强制依赖)
export async function setPushProfileHint(_profile: UserProfileScoring | null): Promise<void> {
// 本期不实现:后端通过现有推荐模块自行拉取/计算 push 文案
}
export async function syncPushPreferencesFromLocal(): Promise<void> {
const s = await getDailyReminderSettings();
await setPushPreferences({ enabled: s.pushEnabled, timesPerDay: s.timesPerDay });
}

View File

@@ -1,7 +1,8 @@
import i18n from 'i18next'; import i18n from 'i18next';
import { API_BASE_URL } from '@/src/constants/env'; import type { UserProfileV1_2 } from '../features/userProfileScoring';
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring'; import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
import { httpJson } from '../utils/http';
export type RecommendedItem = { export type RecommendedItem = {
content_id: number; content_id: number;
@@ -26,19 +27,10 @@ export type RecoRequest = {
now?: string; // ISO8601可选 now?: string; // ISO8601可选
}; };
function withTimeout(ms: number): AbortController {
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller;
}
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> { export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
const controller = withTimeout(12_000); const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
const url = `${API_BASE_URL}/v1/reco/feed`;
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
// 让后端做 locale 选择(目前后端只区分 en/tc // 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': acceptLanguage, 'Accept-Language': acceptLanguage,
}; };
@@ -50,25 +42,39 @@ export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult>
now: req.now, now: req.now,
}; };
// 仅在开发环境打印,避免生产环境日志泄露敏感信息 return await httpJson<RecoEngineResult>({
if (__DEV__) { path: '/v1/reco/feed',
console.log('[Feed API] 请求地址:', url);
console.log('[Feed API] Feed的API请求头:', headers);
console.log('[Feed API] Feed的API请求体:', bodyObj);
}
const res = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(bodyObj), body: bodyObj,
signal: controller.signal, timeoutMs: 12_000,
debugLabel: 'Feed API',
});
}
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
const headers: Record<string, string> = {
// 让后端做 locale 选择(目前后端只区分 en/tc
'Accept-Language': acceptLanguage,
};
const bodyObj = {
k: req.k ?? 1,
user_profile: req.user_profile,
already_recommended_ids: req.already_recommended_ids ?? [],
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
now: req.now,
};
return await httpJson<RecoEngineResult>({
path: '/v1/reco/widget',
method: 'POST',
headers,
body: bodyObj,
timeoutMs: 12_000,
debugLabel: 'Widget API',
}); });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
}
return (await res.json()) as RecoEngineResult;
} }

View File

@@ -1,9 +1,11 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Crypto from 'expo-crypto';
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring'; import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
/** /**
* 本地存储 key 统一管理,避免 UI 里散落硬编码 * 本地存储 key 统一管理,避免 UI 里散落硬编码
*/ */
const KEY_CLIENT_USER_ID = 'client.userId';
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed'; const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
const KEY_PUSH_PROMPT_STATE = 'push.promptState'; const KEY_PUSH_PROMPT_STATE = 'push.promptState';
const KEY_CONTENT_REACTIONS = 'content.reactions'; const KEY_CONTENT_REACTIONS = 'content.reactions';
@@ -14,24 +16,56 @@ const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
const KEY_RECO_FEED_CACHE = 'reco.feedCache'; const KEY_RECO_FEED_CACHE = 'reco.feedCache';
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory'; const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
const KEY_UI_THEME_MODE = 'ui.theme.mode'; const KEY_UI_THEME_MODE = 'ui.theme.mode';
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings'; const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown'; export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike'; export type Reaction = 'like' | 'dislike';
export type ReactionsMap = Record<string, Reaction>; export type ReactionsMap = Record<string, Reaction>;
export type ThemeMode = 'scenery' | 'color'; export type ThemeMode = 'scenery' | 'color' | 'suixin';
export type UserProfile = { export type UserProfile = {
name?: string; name?: string;
intents?: string[]; intents?: string[];
}; };
export type SuixinBaseThemeId =
| 'neutral'
| 'emotional_support'
| 'parenting_pressure'
| 'self_worth'
| 'anxiety_relief'
| 'rest_balance';
export type SuixinThemeStateV1 = {
schema_version: 1;
saved_at: string; // ISO8601
/**
* 冷启动会话标记(进程级)。
* 用于确保:仅在冷启动时重置 base theme/seed。
*/
boot_id: string;
base_theme_id: SuixinBaseThemeId;
seed: string;
step_index: number;
last_color: string; // "#RRGGBB"
};
/** /**
* 用户画像(问卷打分输出) * 用户画像(问卷打分输出)
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。 * 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
*/ */
export type UserProfileScoring = UserProfileV1_2_Extended; export type UserProfileScoring = UserProfileV1_2_Extended;
export type DailyReminderSettings = { export type DailyReminderSettings = {
/**
* 每天推送次数:
* - 0关闭
* - 15每天推送 15 次
*/
timesPerDay: number; timesPerDay: number;
/**
* 仅用于 UI 展示与交互(开关)。
* 后端偏好建议使用enabled = pushEnabled && timesPerDay > 0
*/
pushEnabled: boolean; pushEnabled: boolean;
}; };
@@ -81,6 +115,49 @@ async function setJson<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value)); await AsyncStorage.setItem(key, JSON.stringify(value));
} }
function looksLikeUuid(v: string): boolean {
// 宽松校验8-4-4-4-12不强依赖大小写
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
function uuidV4FromBytes(bytes: Uint8Array): string {
// RFC 4122 v4设置 version 与 variant
const b = new Uint8Array(bytes);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = Array.from(b)
.map((x) => x.toString(16).padStart(2, '0'))
.join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
async function generateUuidV4(): Promise<string> {
// 1) 优先使用运行时提供的 randomUUID如可用
const maybeCrypto = (globalThis as unknown as { crypto?: { randomUUID?: () => string } }).crypto;
if (maybeCrypto?.randomUUID) return maybeCrypto.randomUUID();
// 2) 使用 expo-crypto 生成安全随机数(推荐)
const bytes = await Crypto.getRandomBytesAsync(16);
return uuidV4FromBytes(bytes);
}
/**
* 获取或生成客户端用户标识UUID
*
* 说明:
* - `client_user_id` 用于与后端关联 Push Token 与用户推送偏好
* - 它是“安装实例标识”,不等同真实用户账号
*/
export async function getOrCreateClientUserId(): Promise<string> {
const raw = await AsyncStorage.getItem(KEY_CLIENT_USER_ID);
if (raw && looksLikeUuid(raw)) return raw;
const next = await generateUuidV4();
await AsyncStorage.setItem(KEY_CLIENT_USER_ID, next);
return next;
}
export async function getOnboardingCompleted(): Promise<boolean> { export async function getOnboardingCompleted(): Promise<boolean> {
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED); const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
return raw === 'true'; return raw === 'true';
@@ -151,7 +228,7 @@ export async function setConsentAccepted(accepted: boolean): Promise<void> {
export async function getThemeMode(): Promise<ThemeMode> { export async function getThemeMode(): Promise<ThemeMode> {
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE); const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
if (raw === 'scenery' || raw === 'color') return raw; if (raw === 'scenery' || raw === 'color' || raw === 'suixin') return raw;
return 'scenery'; return 'scenery';
} }
@@ -159,6 +236,28 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode); await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
} }
export async function getSuixinThemeState(): Promise<SuixinThemeStateV1 | null> {
const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<SuixinThemeStateV1>;
if (parsed.schema_version !== 1) return null;
if (typeof parsed.boot_id !== 'string') return null;
if (typeof parsed.base_theme_id !== 'string') return null;
if (typeof parsed.seed !== 'string') return null;
if (typeof parsed.step_index !== 'number') return null;
if (typeof parsed.last_color !== 'string') return null;
if (typeof parsed.saved_at !== 'string') return null;
return parsed as SuixinThemeStateV1;
} catch {
return null;
}
}
export async function setSuixinThemeState(state: SuixinThemeStateV1): Promise<void> {
await setJson(KEY_UI_THEME_SUIXIN_STATE, state);
}
export async function getUserProfile(): Promise<UserProfile> { export async function getUserProfile(): Promise<UserProfile> {
return await getJson<UserProfile>(KEY_USER_PROFILE, {}); return await getJson<UserProfile>(KEY_USER_PROFILE, {});
} }
@@ -272,13 +371,15 @@ export async function getDailyReminderSettings(): Promise<DailyReminderSettings>
}); });
const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3; const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3;
return { return {
timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))), // 需求050 表示关闭)
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
pushEnabled: Boolean(s.pushEnabled), pushEnabled: Boolean(s.pushEnabled),
}; };
} }
export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> { export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> {
const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay))); // 需求050 表示关闭)
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
await setJson(KEY_DAILY_REMINDER_SETTINGS, { await setJson(KEY_DAILY_REMINDER_SETTINGS, {
timesPerDay, timesPerDay,
pushEnabled: Boolean(settings.pushEnabled), pushEnabled: Boolean(settings.pushEnabled),

View File

@@ -0,0 +1,16 @@
/**
* 冷启动会话标记(进程级、仅内存)。
*
* 目的:
* - 在“随心”主题中实现:仅在冷启动时重置 base theme/seed
* - 不落盘,避免污染 AsyncStorage
*/
let bootId: string | null = null;
export function getBootId(): string {
if (bootId) return bootId;
// 说明:无需加密强随机;只要在一次进程周期内稳定、不同冷启动尽量不同即可
bootId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
return bootId;
}

14
client/src/utils/date.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* 生成本地日维度 key用户时区YYYY-MM-DD
*
* 说明:
* - 使用 JS Date 的本地时间字段getFullYear/getMonth/getDate
* - 不依赖额外库,避免时区坑扩大化
*/
export function getLocalDayKey(date: Date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}

163
client/src/utils/http.ts Normal file
View File

@@ -0,0 +1,163 @@
import { API_BASE_URL } from '../constants/env';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export class HttpError extends Error {
readonly name = 'HttpError';
readonly url: string;
readonly status: number;
readonly statusText: string;
readonly responseText?: string;
constructor(args: { message: string; url: string; status: number; statusText: string; responseText?: string }) {
super(args.message);
this.url = args.url;
this.status = args.status;
this.statusText = args.statusText;
this.responseText = args.responseText;
}
}
export type HttpJsonOptions = {
/**
* 支持传入完整 URL 或以 / 开头的 path会自动拼到 API_BASE_URL
*/
path: string;
method?: HttpMethod;
headers?: Record<string, string>;
/**
* 将对象自动 JSON.stringifyGET 请求请不要传 body
*/
body?: unknown;
/**
* 超时(毫秒),默认 10 秒
*/
timeoutMs?: number;
/**
* 外部 signal例如上层取消请求
*/
signal?: AbortSignal;
/**
* 仅开发环境日志:便于联调排查
*/
debugLabel?: string;
};
function isAbsoluteUrl(path: string): boolean {
return /^https?:\/\//i.test(path);
}
function joinUrl(baseUrl: string, path: string): string {
const base = (baseUrl || '').replace(/\/+$/, '');
const p = (path || '').trim();
if (!p) return base;
if (p.startsWith('/')) return `${base}${p}`;
return `${base}/${p}`;
}
function createTimeoutAbortSignal(timeoutMs: number, external?: AbortSignal): AbortController {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
// 避免 Node/Vitest 下悬挂定时器影响退出
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(t as any)?.unref?.();
if (external) {
if (external.aborted) {
controller.abort();
} else {
external.addEventListener(
'abort',
() => {
controller.abort();
},
{ once: true },
);
}
}
return controller;
}
function isDev(): boolean {
return typeof __DEV__ !== 'undefined' && __DEV__;
}
export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
const method = opts.method ?? 'GET';
const timeoutMs = opts.timeoutMs ?? 10_000;
const url = isAbsoluteUrl(opts.path) ? opts.path : joinUrl(API_BASE_URL, opts.path);
const headers: Record<string, string> = {
...(opts.headers ?? {}),
};
const hasBody = typeof opts.body !== 'undefined' && opts.body !== null;
const body = hasBody ? JSON.stringify(opts.body) : undefined;
// 仅在有 body 时默认补齐 Content-Type避免 GET 请求无意义携带
if (hasBody && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
if (isDev() && opts.debugLabel) {
console.log(`[${opts.debugLabel}] 请求地址:`, url);
console.log(`[${opts.debugLabel}] 请求方法:`, method);
console.log(`[${opts.debugLabel}] 请求头:`, headers);
if (hasBody) console.log(`[${opts.debugLabel}] 请求体:`, opts.body);
}
const controller = createTimeoutAbortSignal(timeoutMs, opts.signal);
let res: Response;
try {
res = await fetch(url, {
method,
headers,
body,
signal: controller.signal,
});
} catch (e) {
// RN 下 AbortError 文案不完全一致,这里统一对外语义
const msg = e instanceof Error ? e.message : String(e);
// 带上 URL便于在 TestFlight/Release 排查实际打到哪个地址(例如误打到 localhost
throw new Error(`网络请求失败:${msg}${url}`);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new HttpError({
message: `HTTP 请求失败:${res.status} ${res.statusText} ${text}`.trim(),
url,
status: res.status,
statusText: res.statusText,
responseText: text,
});
}
// 204/205 无内容时不要强行 parse
if (res.status === 204 || res.status === 205) {
return undefined as unknown as T;
}
// 某些后端/网关会返回 200 但 body 为空;此时 res.json() 会抛错,导致客户端误判“失败”。
// 这里改为:先读 text空则返回 undefined非空再 parse JSON。
const text = await res.text().catch(() => '');
if (!text || !String(text).trim()) {
return undefined as unknown as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new HttpError({
message: `HTTP 响应不是合法 JSON${res.status} ${res.statusText} ${text}`.trim(),
url,
status: res.status,
statusText: res.statusText,
responseText: text,
});
}
}

View File

@@ -20,6 +20,9 @@ RUN python -m pip install -U pip \
COPY app /app/app COPY app /app/app
COPY alembic /app/alembic COPY alembic /app/alembic
COPY alembic.ini /app/alembic.ini COPY alembic.ini /app/alembic.ini
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 8000 EXPOSE 8000
@@ -29,4 +32,7 @@ EXPOSE 8000
# - 参考文档server/README.md # - 参考文档server/README.md
# 生产镜像默认不开启 reload # 生产镜像默认不开启 reload
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # 默认行为:只启动 API与之前一致
# 如需同时启动定时推送相关进程Celery Worker/Beat可在运行时注入
# -e START_ALL=1
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@@ -0,0 +1,123 @@
"""init push tables
Revision ID: 0002_init_push_tables
Revises: 0001_init_content_tables
Create Date: 2026-02-03
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "0002_init_push_tables"
down_revision = "0001_init_content_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
# push_tokens
op.create_table(
"push_tokens",
sa.Column(
"id",
mysql.BIGINT(unsigned=True),
primary_key=True,
autoincrement=True,
comment="主键",
),
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识UUID"),
sa.Column(
"platform",
sa.Enum("ios", "android", name="push_platform"),
nullable=False,
comment="平台",
),
sa.Column("push_token", sa.String(length=255), nullable=False, comment="Expo Push Token"),
sa.Column("app_id", sa.String(length=255), nullable=False, comment="bundle id / package name用于隔离"),
sa.Column(
"env",
sa.Enum("dev", "prod", name="push_env"),
nullable=False,
comment="环境隔离",
),
sa.Column(
"is_active",
sa.Boolean(),
server_default=sa.text("1"),
nullable=False,
comment="是否有效(发送失败且不可恢复时置为 false",
),
sa.Column("last_seen_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="最后一次上报时间"),
sa.UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
mysql_charset="utf8mb4",
)
op.create_index("idx_push_tokens_client_user_id", "push_tokens", ["client_user_id"], unique=False)
op.create_index("idx_push_tokens_is_active", "push_tokens", ["is_active"], unique=False)
# push_preferences
op.create_table(
"push_preferences",
sa.Column("client_user_id", sa.String(length=64), primary_key=True, comment="客户端用户标识UUID"),
sa.Column(
"enabled",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
comment="是否开启每日提醒enabled=false 或 times_per_day=0 均视为关闭)",
),
sa.Column(
"times_per_day",
sa.SmallInteger(),
server_default="0",
nullable=False,
comment="每天推送次数05",
),
sa.Column("timezone", sa.String(length=64), nullable=True, comment="IANA 时区(例如 Asia/Shanghai来自客户端上报"),
sa.Column("locale", sa.String(length=32), nullable=True, comment="客户端语言(例如 zh-CN/en/zh-TW用于文案语言选择"),
sa.Column("user_profile_json", sa.JSON(), nullable=True, comment="用户画像V1.2;可选)。用于 Push 场景推荐文案生成。"),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
mysql_charset="utf8mb4",
)
# push_send_log
op.create_table(
"push_send_log",
sa.Column(
"id",
mysql.BIGINT(unsigned=True),
primary_key=True,
autoincrement=True,
comment="主键",
),
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识UUID"),
sa.Column("local_date", sa.Date(), nullable=False, comment="用户时区的本地日期(用于幂等)"),
sa.Column("slot_index", sa.SmallInteger(), nullable=False, comment="当天第几条1..times_per_day"),
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="计划发送时间UTC"),
sa.Column("sent_at", sa.DateTime(), nullable=True, comment="实际发送时间UTC"),
sa.Column("status", sa.String(length=16), server_default="scheduled", nullable=False, comment="scheduled/sent/failed"),
sa.Column("error", sa.Text(), nullable=True, comment="失败原因(可选)"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
sa.UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
mysql_charset="utf8mb4",
)
op.create_index("idx_push_log_user_date", "push_send_log", ["client_user_id", "local_date"], unique=False)
op.create_index("idx_push_log_status", "push_send_log", ["status"], unique=False)
def downgrade() -> None:
op.drop_index("idx_push_log_status", table_name="push_send_log")
op.drop_index("idx_push_log_user_date", table_name="push_send_log")
op.drop_table("push_send_log")
op.drop_table("push_preferences")
op.drop_index("idx_push_tokens_is_active", table_name="push_tokens")
op.drop_index("idx_push_tokens_client_user_id", table_name="push_tokens")
op.drop_table("push_tokens")

View File

@@ -47,6 +47,7 @@ class FixedWindowRateLimiter:
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60) _reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
_push_rate_limiter = FixedWindowRateLimiter(limit=30, window_seconds=60)
async def rate_limit_reco_by_ip(request: Request) -> None: async def rate_limit_reco_by_ip(request: Request) -> None:
@@ -60,3 +61,19 @@ async def rate_limit_reco_by_ip(request: Request) -> None:
_reco_rate_limiter.allow(key=ip, now_ts=time.time()) _reco_rate_limiter.allow(key=ip, now_ts=time.time())
async def rate_limit_push_by_ip(request: Request) -> None:
"""
推送相关接口限流:按 IP1 分钟 30 次。
说明:
- register/preferences 等接口可能在客户端反复重试
- 本期先用内存固定窗口限流做基础保护
"""
ip = "unknown"
if request.client and request.client.host:
ip = str(request.client.host)
_push_rate_limiter.allow(key=ip, now_ts=time.time())

131
server/app/api/v1/legal.py Normal file
View File

@@ -0,0 +1,131 @@
from __future__ import annotations
from typing import Literal, Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, HttpUrl
from app.core.config import get_settings
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
router = APIRouter(prefix="/v1/legal", tags=["legal"])
INTERNAL_SENTINEL = "__internal__"
# 当前多语言仅支持 EN / TC繁体
ResolvedLang = Literal["en", "tc"]
class LegalLinksResponse(BaseModel):
privacyPolicyUrl: HttpUrl
termsOfUseUrl: HttpUrl
resolvedLang: ResolvedLang
def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
"""
从 Accept-Language 里做一个轻量语言解析。
说明:
- 只关心en / tc繁体
- 解析失败或缺失:回退 en
"""
if not accept_language:
return "en"
s = accept_language.lower()
# 目前只支持 EN / TC只要是中文或显式 tc都归到 tc
if "tc" in s:
return "tc"
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
return "tc"
return "en"
def _pick_urls(lang: ResolvedLang) -> tuple[str, str, ResolvedLang]:
settings = get_settings()
if lang == "tc":
privacy = settings.legal_privacy_url_tc or settings.legal_privacy_url_en
terms = settings.legal_terms_url_tc or settings.legal_terms_url_en
# 如果未配置 tc 链接:
# - 若走内置页面__internal__可直接展示 tc 内容,因此 resolved=tc
# - 否则回退到 en 链接,因此 resolved=en
if settings.legal_privacy_url_tc or settings.legal_terms_url_tc:
resolved: ResolvedLang = "tc"
elif privacy == INTERNAL_SENTINEL or terms == INTERNAL_SENTINEL:
resolved = "tc"
else:
resolved = "en"
return privacy, terms, resolved
return settings.legal_privacy_url_en, settings.legal_terms_url_en, "en"
def _join_base_url(base_url: str, path: str) -> str:
base = (base_url or "").rstrip("/")
p = (path or "").strip()
if not p.startswith("/"):
p = "/" + p
return base + p
def _normalize_internal_url(request: Request, url: str, internal_path: str) -> str:
"""
将内置哨兵值替换为当前服务的可访问绝对 URL。
"""
if url != INTERNAL_SENTINEL:
return url
return _join_base_url(str(request.base_url), internal_path)
@router.get("/links", response_model=LegalLinksResponse)
async def get_legal_links(request: Request) -> LegalLinksResponse:
"""
获取协议链接(隐私协议 / 使用协议)。
- 语言来源Accept-Language
- 默认兜底en
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
privacy, terms, resolved = _pick_urls(lang)
privacy = _normalize_internal_url(request, privacy, "/v1/legal/privacy")
terms = _normalize_internal_url(request, terms, "/v1/legal/terms")
return LegalLinksResponse(privacyPolicyUrl=privacy, termsOfUseUrl=terms, resolvedLang=resolved)
@router.get("/privacy", response_class=HTMLResponse)
async def get_privacy_policy(request: Request) -> HTMLResponse:
"""
内置隐私协议页面(用于未配置外部托管链接时的兜底)。
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
title = "Hey Mama | Privacy Policy" if resolved == "en" else "Hey Mama隱私權政策"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
@router.get("/terms", response_class=HTMLResponse)
async def get_terms_of_use(request: Request) -> HTMLResponse:
"""
内置使用协议页面(用于未配置外部托管链接时的兜底)。
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
title = "Hey Mama Terms of Use" if resolved == "en" else "Hey Mama 使用條款"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})

262
server/app/api/v1/push.py Normal file
View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal, Optional
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.limits import rate_limit_push_by_ip
from app.core.config import get_settings
from app.db.models.push_preference import PushPreference
from app.db.models.push_token import PushToken
from app.db.session import get_db
from app.features.user_profile_scoring.types import UserProfileV1_2
router = APIRouter(
prefix="/v1/push",
tags=["push"],
dependencies=[Depends(rate_limit_push_by_ip)],
)
PushEnv = Literal["dev", "prod"]
PushPlatform = Literal["ios", "android"]
class PushDeviceMeta(BaseModel):
model: Optional[str] = None
os_version: Optional[str] = None
app_version: Optional[str] = None
locale: Optional[str] = None
timezone: Optional[str] = None
class PushRegisterRequest(BaseModel):
client_user_id: str = Field(min_length=8, max_length=64)
platform: PushPlatform
push_token: str = Field(min_length=8, max_length=255)
app_id: str = Field(min_length=1, max_length=255)
env: PushEnv
device_meta: Optional[PushDeviceMeta] = None
class PushPreferencesRequest(BaseModel):
client_user_id: str = Field(min_length=8, max_length=64)
enabled: bool
times_per_day: int = Field(ge=0, le=5)
timezone: Optional[str] = None
locale: Optional[str] = None
# 可选:用户画像(用于 Push 场景推荐文案生成)
user_profile: Optional[UserProfileV1_2] = None
class PushPreferencesResponse(BaseModel):
client_user_id: str
enabled: bool
times_per_day: int
timezone: Optional[str] = None
locale: Optional[str] = None
updated_at: Optional[str] = None
class PushTestRequest(BaseModel):
client_user_id: str = Field(min_length=8, max_length=64)
title: Optional[str] = None
body: Optional[str] = None
def _ensure_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
async def _pick_active_token(db: AsyncSession, *, client_user_id: str) -> Optional[PushToken]:
# 取最近一次上报的 active token
q = (
select(PushToken)
.where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712
.order_by(PushToken.last_seen_at.desc())
.limit(1)
)
row = await db.execute(q)
return row.scalar_one_or_none()
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
"""
发送 Expo Push。
说明:
- V1最小可用实现满足 test 与后续定时任务调用
- 失败处理与 token 停用在后续任务逻辑中完善
"""
settings = get_settings()
url = "https://exp.host/--/api/v2/push/send"
headers: dict[str, str] = {
"Content-Type": "application/json",
}
if settings.expo_access_token:
headers["Authorization"] = f"Bearer {settings.expo_access_token}"
payload: dict[str, Any] = {"to": to, "title": title, "body": body}
if data:
payload["data"] = data
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(url, headers=headers, json=payload)
if res.status_code >= 400:
raise HTTPException(status_code=502, detail=f"expo_push_failed:{res.status_code}")
return res.json()
@router.post("/register")
async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict[str, str]:
"""
注册/更新 Push Token幂等
"""
now = datetime.now(timezone.utc)
# 以 env+app_id+push_token 唯一:存在则更新归属与 last_seen
q = select(PushToken).where(
PushToken.env == req.env,
PushToken.app_id == req.app_id,
PushToken.push_token == req.push_token,
)
row = await db.execute(q)
token = row.scalar_one_or_none()
if token is None:
token = PushToken(
client_user_id=req.client_user_id,
platform=req.platform,
push_token=req.push_token,
app_id=req.app_id,
env=req.env,
is_active=True,
last_seen_at=_ensure_utc(now),
)
db.add(token)
else:
token.client_user_id = req.client_user_id
token.platform = req.platform
token.is_active = True
token.last_seen_at = _ensure_utc(now)
await db.commit()
return {"status": "ok"}
@router.put("/preferences", response_model=PushPreferencesResponse)
async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depends(get_db)) -> PushPreferencesResponse:
"""
设置每日提醒偏好(幂等)。
"""
# 规范化enabled=false 或 times=0 均视为关闭
times = int(req.times_per_day)
enabled = bool(req.enabled) and times > 0
q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
row = await db.execute(q)
pref = row.scalar_one_or_none()
if pref is None:
pref = PushPreference(
client_user_id=req.client_user_id,
enabled=enabled,
times_per_day=times,
timezone=req.timezone,
locale=req.locale,
# 注意Pydantic 会把 ISO8601 字符串解析成 datetime
# SQLAlchemy JSON 列默认使用 json.dumps无法序列化 datetime。
# 这里用 mode="json" 保证写库内容都是可 JSON 序列化的基础类型datetime → ISO 字符串)。
user_profile_json=req.user_profile.model_dump(mode="json") if req.user_profile else None,
)
db.add(pref)
else:
pref.enabled = enabled
pref.times_per_day = times
pref.timezone = req.timezone
pref.locale = req.locale
if req.user_profile is not None:
pref.user_profile_json = req.user_profile.model_dump(mode="json")
await db.commit()
# 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate这里用 now 兜底)
updated_at = getattr(pref, "updated_at", None)
updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None
return PushPreferencesResponse(
client_user_id=req.client_user_id,
enabled=enabled,
times_per_day=times,
timezone=req.timezone,
locale=req.locale,
updated_at=updated_at_iso,
)
@router.get("/preferences", response_model=PushPreferencesResponse)
async def get_preferences(
client_user_id: str = Query(min_length=8, max_length=64),
db: AsyncSession = Depends(get_db),
) -> PushPreferencesResponse:
q = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
row = await db.execute(q)
pref = row.scalar_one_or_none()
if pref is None:
return PushPreferencesResponse(client_user_id=client_user_id, enabled=False, times_per_day=0)
updated_at_iso = pref.updated_at.isoformat() if pref.updated_at else None
return PushPreferencesResponse(
client_user_id=client_user_id,
enabled=bool(pref.enabled) and int(pref.times_per_day) > 0,
times_per_day=int(pref.times_per_day),
timezone=pref.timezone,
locale=pref.locale,
updated_at=updated_at_iso,
)
@router.post("/test")
async def test_push(
req: PushTestRequest,
db: AsyncSession = Depends(get_db),
accept_language: Optional[str] = Header(default=None, alias="Accept-Language"),
) -> dict[str, Any]:
"""
立即测试推送(仅用于 dev 联调)。
"""
settings = get_settings()
if settings.app_env != "dev":
raise HTTPException(status_code=403, detail="test_only_in_dev")
token = await _pick_active_token(db, client_user_id=req.client_user_id)
if token is None:
raise HTTPException(status_code=404, detail="no_active_token")
# 如果没有用户画像,也不影响 test 推送;文案按请求/默认文案发送。
q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
row = await db.execute(q)
pref = row.scalar_one_or_none()
if pref and pref.user_profile_json:
_ = UserProfileV1_2.model_validate(pref.user_profile_json)
# V1先发固定测试文案后续在定时任务中替换为推荐模块的 push 场景模板
title = req.title or "Hey Mama"
body = req.body or "这是一条测试推送dev"
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
_ = accept_language
return {"status": "ok", "expo": expo_res}

View File

@@ -46,6 +46,24 @@ class Settings(BaseSettings):
celery_broker_url: str celery_broker_url: str
celery_result_backend: Optional[str] = None celery_result_backend: Optional[str] = None
# 法务协议链接(由后端统一托管并下发给客户端)
# 说明:
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
# - 若未显式配置 LEGAL_*,后端会使用“内置协议页面”(/v1/legal/privacy、/v1/legal/terms
# - 线上建议配置为你们的官网/托管页/静态站点 HTTPS 链接,避免与 API 域名强绑定
#
# 这里用一个内部哨兵值表示“走内置页面”,避免默认值误导为可用的公网链接。
legal_privacy_url_en: str = "__internal__"
legal_terms_url_en: str = "__internal__"
legal_privacy_url_tc: Optional[str] = None
legal_terms_url_tc: Optional[str] = None
# Expo Push可选
# 说明:
# - 不填也能调用 Expo Push API但会受更严格的速率限制
# - 建议生产环境配置 EXPO_ACCESS_TOKEN便于稳定性与配额
expo_access_token: Optional[str] = None
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="", env_prefix="",
case_sensitive=False, case_sensitive=False,

View File

@@ -9,6 +9,9 @@
from app.db.models.content import Content from app.db.models.content import Content
from app.db.models.content_profile import ContentProfile from app.db.models.content_profile import ContentProfile
from app.db.models.content_risk_flag import ContentRiskFlag from app.db.models.content_risk_flag import ContentRiskFlag
from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken
__all__ = ["Content", "ContentProfile", "ContentRiskFlag"] __all__ = ["Content", "ContentProfile", "ContentRiskFlag", "PushPreference", "PushSendLog", "PushToken"]

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, JSON, SmallInteger, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PushPreference(Base):
"""
用户推送偏好(以 client_user_id 为主键)。
"""
__tablename__ = "push_preferences"
client_user_id: Mapped[str] = mapped_column(
String(length=64),
primary_key=True,
comment="客户端用户标识UUID",
)
enabled: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
server_default="0",
comment="是否开启每日提醒enabled=false 或 times_per_day=0 均视为关闭)",
)
times_per_day: Mapped[int] = mapped_column(
SmallInteger,
nullable=False,
server_default="0",
comment="每天推送次数05",
)
timezone: Mapped[str | None] = mapped_column(
String(length=64),
nullable=True,
comment="IANA 时区(例如 Asia/Shanghai来自客户端上报",
)
locale: Mapped[str | None] = mapped_column(
String(length=32),
nullable=True,
comment="客户端语言(例如 zh-CN/en/zh-TW用于文案语言选择",
)
user_profile_json: Mapped[dict | None] = mapped_column(
JSON,
nullable=True,
comment="用户画像V1.2;可选)。用于 Push 场景推荐文案生成。",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
server_onupdate=func.now(),
comment="更新时间",
)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class PushSendLog(Base):
"""
推送发送日志(用于幂等防重复 + 可观测)。
"""
__tablename__ = "push_send_log"
__table_args__ = (
UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
Index("idx_push_log_user_date", "client_user_id", "local_date"),
Index("idx_push_log_status", "status"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识UUID")
local_date: Mapped[date] = mapped_column(Date, nullable=False, comment="用户时区的本地日期(用于幂等)")
slot_index: Mapped[int] = mapped_column(SmallInteger, nullable=False, comment="当天第几条1..times_per_day")
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="计划发送时间UTC")
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="实际发送时间UTC")
status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed")
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from sqlalchemy import Boolean, DateTime, Enum, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
PushEnv = Literal["dev", "prod"]
PushPlatform = Literal["ios", "android"]
class PushToken(Base):
"""
Push Token 绑定表Expo Push Token
约束:
- token 在同一 env + app_id 下必须唯一(避免重复推送)
- 同一 client_user_id 可能会更新 token重装/轮换/重新授权)
"""
__tablename__ = "push_tokens"
__table_args__ = (
UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
Index("idx_push_tokens_client_user_id", "client_user_id"),
Index("idx_push_tokens_is_active", "is_active"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识UUID")
platform: Mapped[PushPlatform] = mapped_column(
Enum("ios", "android", name="push_platform"),
nullable=False,
comment="平台",
)
push_token: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="Expo Push Token")
app_id: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="bundle id / package name用于隔离")
env: Mapped[PushEnv] = mapped_column(
Enum("dev", "prod", name="push_env"),
nullable=False,
comment="环境隔离",
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
server_default="1",
comment="是否有效(发送失败且不可恢复时置为 false",
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
server_onupdate=func.now(),
comment="最后一次上报时间",
)

305
server/app/legal_docs.py Normal file
View File

@@ -0,0 +1,305 @@
from __future__ import annotations
import html
import re
from typing import Literal
ResolvedLang = Literal["en", "tc"]
# 协议原文(直接来自仓库中的 Markdown 文档)。
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
PRIVACY_POLICY_MD = """Hey Mama | Privacy Policy
Last updated: February 2026
1. Introduction
Welcome to Hey Mama (“the App,” “we,” “us”).
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
2. Data Controller and Scope
The App is operated and maintained by the Hey Mama team.
This Privacy Policy applies to information processing activities related to your use of the App.
3. Information We Collect
3.1 Information You Provide
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
During your use of the App, you may optionally provide or generate the following information:
- Reminder settings (e.g., reminder frequency)
- Text content you view, save as favorites, or create within the App (if available)
This information is used only to operate core App features and provide a personalized experience.
3.2 Information Collected Automatically
When you use the App, we may automatically collect certain non-identifiable technical information, including but not limited to:
- Device type and operating system version
- App version
- Device language settings
- Basic usage status (e.g., whether the App is opened, whether reminders are enabled)
This information does not directly identify you and is primarily used to maintain App stability, troubleshoot issues, and improve user experience.
The App does not collect precise location information for the purposes described in this policy, does not engage in cross-app tracking, and does not use your data for third-party advertising.
4. Push Notifications
With your permission, the App may send you reminder notifications, such as daily affirmations.
- Notifications contain general text information only
- Notifications do not include sensitive personal data
- You can disable notifications at any time in your device settings
5. Home Screen Widgets
If you choose to use home screen widgets, the displayed content comes from the Apps text-based affirmations. Widgets do not collect or transmit additional personal data.
6. How We Use Information
We use collected information only for the following purposes:
- To provide and maintain core App functionality
- To improve content presentation and user experience
- To fix bugs and enhance system stability
We do not:
- Sell, rent, or trade your personal data
- Use your data for third-party advertising purposes
7. Third-Party Services
Hey Mama currently does not integrate third-party advertising or marketing services.
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
8. Data Retention and Security
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
9. Minors
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
10. Changes to This Privacy Policy
We may update this Privacy Policy from time to time. The updated version will be made available within the App or through related pages. If you continue to use the App after updates take effect, you are deemed to have accepted the updated policy.
---
Hey Mama隱私權政策
最後更新日期2026 年 2 月
一、前言
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
當您下載、存取或使用本 App即表示您已閱讀、理解並同意本隱私權政策之內容。
二、我們收集的資訊
1. 使用者主動提供的資訊
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
在使用過程中,您可能會選擇性提供或產生以下資訊:
- 提醒設定(例如提醒頻率)
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
上述資訊僅用於 App 功能運作與個人化體驗。
2. 自動收集的資訊
當您使用本 App 時,我們可能會自動收集部分非識別性技術資訊,包括但不限於:
- 裝置類型與作業系統版本
- App 版本
- 裝置語言設定
- 基本使用行為(例如是否開啟 App、是否啟用提醒
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
三、推送通知
在取得您同意後Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
- 推送內容僅包含一般文字資訊
- 不包含任何敏感個人資料
- 您可隨時於裝置系統設定中關閉通知功能
四、桌面小組件
若您選擇使用桌面小組件,其顯示內容僅來自 App 內的文字肯定語,不會額外收集或傳送新的個人資料。
五、資訊使用方式
我們僅於下列目的範圍內使用所收集的資訊:
- 提供與維護 App 的基本功能
- 改善內容呈現與使用體驗
- 修復錯誤與提升系統穩定性
我們不會:
- 出售、出租或交換您的個人資料
- 將資料用於第三方廣告投放
六、第三方服務
目前 Hey Mama 未整合第三方廣告或行銷服務。
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
七、資料保存與安全
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
八、未成年人說明
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
若您為未成年人,請在監護人同意與陪同下使用本 App。
九、隱私權政策的變更
我們可能會不定期更新本隱私權政策。
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App即視為同意更新內容。
"""
TERMS_OF_USE_MD = """Hey Mama Terms of Use
Last updated: February 2026
Welcome to Hey Mama (“the App,” “we,” or “us”).
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
1. Intended Audience
Hey Mama is intended for adults only.
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
2. Services Provided
Hey Mama provides text-based content and features, including but not limited to:
- Daily affirmations and mindfulness text
- User-configured reminders and push notifications
- Home screen widgets displaying affirmation text
- Personalized reading or saving experiences (where applicable)
All content is provided for general emotional support and self-reflection purposes only and does not constitute medical, psychological, or professional advice.
3. Acceptable Use
You agree to:
- Use the App for personal, non-commercial purposes only
- Not copy, reproduce, distribute, sell, modify, reverse engineer, or attempt to extract the source code of the App
- Not engage in any activity that may interfere with the Apps functionality, stability, or user experience
We reserve the right to restrict or terminate access if these Terms are violated.
4. Push Notifications
The App may send push notifications based on your settings (e.g. daily affirmation reminders).
- Notifications contain general text only
- No sensitive personal data is included
- You may disable notifications at any time through your device settings
5. Intellectual Property
All content, design elements, interfaces, and materials within the App are owned by us or our licensors and are protected by applicable intellectual property laws.
Unauthorized use, reproduction, or distribution is strictly prohibited.
6. Disclaimer
The App and its content are provided for informational and self-support purposes only.
- We do not guarantee specific emotional or psychological outcomes
- The App does not replace professional medical, mental health, or legal advice
- You are solely responsible for how you use the content
7. Service Availability
We may modify, suspend, or discontinue any part of the App at any time due to maintenance, updates, or circumstances beyond our control.
We are not liable for any loss or damage resulting from such interruptions or changes.
8. Changes to These Terms
We may update these Terms of Use from time to time.
Updated versions will be made available within the App or related pages. Continued use of the App after changes indicates acceptance of the revised Terms.
9. Governing Law
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
---
Hey Mama 使用條款
最後更新日期2026 年 2 月
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App即表示您已閱讀、理解並同意遵守本條款。
1. 服務對象與使用資格
Hey Mama 僅供成年人使用intended for adults
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
2. 服務內容
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
- 每日肯定語與正念文字內容
- 使用者設定的提醒與推送通知
- 桌面小組件顯示肯定語文字
- 內容閱讀與收藏等個人化體驗(如適用)
本 App 所提供之內容僅作為日常情緒支持與自我提醒用途,不構成任何形式的醫療、心理諮商或專業建議。
3. 使用方式與限制
使用者同意:
- 僅將本 App 用於個人、非商業用途
- 不以任何方式複製、重製、散布、出售、反編譯或試圖取得本 App 之原始碼
- 不進行任何可能影響 App 正常運作、穩定性或其他使用者體驗之行為
如有違反,我們有權在不另行通知的情況下限制或終止使用權限。
4. 推送通知
本 App 可能依使用者設定發送推送通知(例如每日肯定語提醒)。
- 推送內容僅為一般文字資訊
- 不包含個人化敏感資料
- 您可隨時透過裝置系統設定關閉通知功能
5. 智慧財產權
本 App 及其所有內容(包含但不限於文字、設計、介面、版面配置與視覺元素)之智慧財產權,均屬於我們或合法授權方所有。
未經事前書面同意,任何形式之使用、修改、重製或散布皆屬禁止。
6. 免責聲明
本 App 所提供之內容僅供一般參考與自我提醒之用。
- 我們不保證內容能產生特定心理、情緒或行為結果
- 本 App 不取代任何專業醫療、心理或法律建議
- 使用者應自行判斷內容是否適合自身狀況
7. 服務中斷與變更
我們可能因系統維護、功能調整、更新或其他不可抗力因素,暫時中斷或變更本 App 之全部或部分功能。
對於因此可能造成的任何直接或間接損失,我們不負任何責任。
8. 條款修改
我們可能不定期更新本使用條款。
更新後的版本將公布於 App 內或相關頁面,您於條款更新後繼續使用本 App即視為同意更新內容。
9. 準據法與管轄
本使用條款之解釋與適用,悉依我們所在地之相關法律規定處理。
"""
def split_bilingual_markdown(text: str) -> tuple[str, str | None]:
"""
按文档中的 `---` 分隔符将内容拆成两段。
返回:
- 第一段:默认视为英文
- 第二段:默认视为繁体(可为空)
"""
parts = re.split(r"^\s*---\s*$", text, maxsplit=1, flags=re.MULTILINE)
if not parts:
return "", None
if len(parts) == 1:
return parts[0].strip(), None
return parts[0].strip(), parts[1].strip()
def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, ResolvedLang]:
"""
根据 lang 选择展示内容。若 tc 内容缺失则回退 en。
"""
en, tc = split_bilingual_markdown(text)
if lang == "tc" and tc:
return tc, "tc"
return en, "en"
def render_as_simple_html(title: str, content: str) -> str:
"""
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
"""
safe_title = html.escape(title)
safe_content = html.escape(content)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{safe_title}</title>
<style>
:root {{
color-scheme: light dark;
}}
body {{
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "PingFang TC", "PingFang SC", "Noto Sans CJK TC", "Noto Sans CJK SC", sans-serif;
line-height: 1.6;
}}
pre {{
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-size: 15px;
}}
</style>
</head>
<body>
<pre>{safe_content}</pre>
</body>
</html>
"""

View File

@@ -3,6 +3,8 @@ from fastapi import FastAPI
from app.core.config import get_settings from app.core.config import get_settings
from app.api.v1.reco import router as reco_router from app.api.v1.reco import router as reco_router
from app.api.v1.user_profile_scoring import router as user_profile_router from app.api.v1.user_profile_scoring import router as user_profile_router
from app.api.v1.legal import router as legal_router
from app.api.v1.push import router as push_router
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -19,6 +21,8 @@ def create_app() -> FastAPI:
# 业务路由 # 业务路由
app.include_router(user_profile_router) app.include_router(user_profile_router)
app.include_router(reco_router) app.include_router(reco_router)
app.include_router(legal_router)
app.include_router(push_router)
@app.get("/health") @app.get("/health")
async def health() -> dict: async def health() -> dict:

View File

@@ -2,3 +2,12 @@
Celery 任务集合。 Celery 任务集合。
""" """
# 重要:
# - 本项目的任务按文件拆分在 app/tasks/*.py 中
# - Celery autodiscover 通常只会导入 app.tasks即本包不会自动递归导入子模块
# - 因此这里需要显式导入各任务模块,确保 shared_task 被注册
from app.tasks import ping as _ping # noqa: F401
from app.tasks import reco as _reco # noqa: F401
from app.tasks import push as _push # noqa: F401

312
server/app/tasks/push.py Normal file
View File

@@ -0,0 +1,312 @@
from __future__ import annotations
import asyncio
import random
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
import httpx
from celery import current_app, shared_task
from sqlalchemy import select
from app.core.config import get_settings
from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken
from app.db.session import AsyncSessionLocal
from app.features.personalized_reco.content_repository.types import normalize_locale
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
def _ensure_tz(tz_name: Optional[str]) -> ZoneInfo:
raw = (tz_name or "").strip()
if not raw:
return ZoneInfo("UTC")
try:
return ZoneInfo(raw)
except Exception:
return ZoneInfo("UTC")
def _pick_reco_locale(pref_locale: Optional[str]) -> str:
"""
将客户端 locale如 zh-CN/en/zh-TW映射到推荐系统 localeen/tc
"""
raw = (pref_locale or "").strip().lower()
if raw.startswith("zh"):
return "tc"
# 其他语言在当前版本统一回退到 en与现有 reco/ legal 链路一致)
return "en"
def _pick_title(locale: str) -> str:
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
settings = get_settings()
url = "https://exp.host/--/api/v2/push/send"
headers: dict[str, str] = {"Content-Type": "application/json"}
if settings.expo_access_token:
headers["Authorization"] = f"Bearer {settings.expo_access_token}"
payload: dict[str, Any] = {"to": to, "title": title, "body": body}
if data:
payload["data"] = data
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(url, headers=headers, json=payload)
res.raise_for_status()
return res.json()
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
"""
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
"""
if n <= 0:
return []
total = (end - start).total_seconds()
if total <= 0:
return []
out: list[datetime] = []
for i in range(n):
seg_start = start + timedelta(seconds=total * i / n)
seg_end = start + timedelta(seconds=total * (i + 1) / n)
seg = (seg_end - seg_start).total_seconds()
if seg <= 0:
out.append(seg_start)
continue
jitter = random.random() * seg
out.append(seg_start + timedelta(seconds=jitter))
return out
@dataclass(frozen=True)
class _ScheduleTarget:
local_date: date
start_local: datetime
end_local: datetime
def _pick_schedule_target(*, now_utc: datetime, tz: ZoneInfo) -> _ScheduleTarget:
"""
为某个用户选择“要生成计划的本地日期”:
- 若当前本地时间 < 09:00生成“今天”
- 否则:生成“明天”
目的:确保生成的时间点尽量都在未来,避免任务 ETA 立刻触发导致体验异常。
"""
now_local = now_utc.astimezone(tz)
today = now_local.date()
day = today if now_local.time() < time(9, 0) else (today + timedelta(days=1))
start_local = datetime.combine(day, time(9, 0), tzinfo=tz)
end_local = datetime.combine(day + timedelta(days=1), time(0, 0), tzinfo=tz) # 24:00
return _ScheduleTarget(local_date=day, start_local=start_local, end_local=end_local)
async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -> dict[str, int]:
"""
为所有开启每日提醒的用户生成当天/明天的推送计划,并投递 ETA 发送任务。
幂等:
- `push_send_log` 唯一键client_user_id + local_date + slot_index保证同一天不会重复排程
- 即使重复运行,最多只会补齐缺失 slot
"""
created = 0
scheduled = 0
async with AsyncSessionLocal() as session:
q = (
select(PushPreference)
.where(PushPreference.enabled == True, PushPreference.times_per_day > 0) # noqa: E712
.limit(int(max_users))
)
rows = await session.execute(q)
prefs = list(rows.scalars().all())
for pref in prefs:
tz = _ensure_tz(pref.timezone)
target = _pick_schedule_target(now_utc=now_utc, tz=tz)
n = int(pref.times_per_day or 0)
n = max(0, min(5, n))
if n <= 0:
continue
times_local = _uniform_jitter_times(start=target.start_local, end=target.end_local, n=n)
for idx, dt_local in enumerate(times_local, start=1):
dt_utc = dt_local.astimezone(timezone.utc)
# 先尝试插入日志(幂等)
log = PushSendLog(
client_user_id=pref.client_user_id,
local_date=target.local_date,
slot_index=int(idx),
scheduled_at=dt_utc.replace(tzinfo=None), # DB 存 naive约定为 UTC
status="scheduled",
)
session.add(log)
try:
await session.commit()
except Exception:
await session.rollback()
# 可能已存在(唯一键冲突),跳过
continue
created += 1
# 投递 ETA 发送任务
current_app.send_task(
"tasks.push.send_scheduled",
kwargs={
"client_user_id": pref.client_user_id,
"local_date": target.local_date.isoformat(),
"slot_index": int(idx),
},
eta=dt_utc,
)
scheduled += 1
return {"created": created, "scheduled": scheduled}
@shared_task(name="tasks.push.generate_daily_schedule")
def generate_daily_schedule(*, max_users: int = 5000) -> dict[str, int]:
"""
生成每日推送计划(入口任务)。
说明:
- 建议由 celery beat 定时触发(见 app/worker.py
- 入参尽量小,避免 Redis 队列膨胀
"""
now_utc = datetime.now(timezone.utc)
return asyncio.run(_generate_schedule_once(now_utc=now_utc, max_users=int(max_users)))
async def _send_once_async(*, client_user_id: str, local_date: date, slot_index: int) -> dict[str, Any]:
async with AsyncSessionLocal() as session:
# 1) 查 log避免重复发送
qlog = select(PushSendLog).where(
PushSendLog.client_user_id == client_user_id,
PushSendLog.local_date == local_date,
PushSendLog.slot_index == int(slot_index),
)
rlog = await session.execute(qlog)
log = rlog.scalar_one_or_none()
if log is None:
return {"status": "noop", "reason": "no_log"}
if str(log.status) == "sent":
return {"status": "noop", "reason": "already_sent"}
# 2) 当前偏好检查(用户可能中途关闭/改次数)
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
rpref = await session.execute(qpref)
pref = rpref.scalar_one_or_none()
if pref is None or (not bool(pref.enabled)) or int(pref.times_per_day or 0) < int(slot_index):
log.status = "skipped"
log.error = "disabled_or_reduced"
await session.commit()
return {"status": "skipped"}
# 3) 找 token
qtok = (
select(PushToken)
.where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712
.order_by(PushToken.last_seen_at.desc())
.limit(1)
)
rtok = await session.execute(qtok)
token = rtok.scalar_one_or_none()
if token is None:
log.status = "failed"
log.error = "no_active_token"
await session.commit()
return {"status": "failed", "reason": "no_active_token"}
# 4) 生成文案(复用推荐模块 push 场景)
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
title = _pick_title(reco_locale)
if pref.user_profile_json:
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
else:
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
user_profile = UserProfileV1_2.model_validate(
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
)
# 直接复用 reco 的 Celery 任务实现(同步函数)
from app.tasks.reco import generate as reco_generate
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
body = ""
try:
items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list):
body = str(items[0].get("text") or "").strip()
except Exception:
body = ""
if not body:
body = "给自己一句温柔的话。"
# 5) 发送
try:
expo_res = await _send_expo_push(
to=str(token.push_token),
title=title,
body=body,
data={"client_user_id": client_user_id, "scene": "push"},
)
except Exception as e:
log.status = "failed"
log.error = f"send_failed:{type(e).__name__}"
await session.commit()
return {"status": "failed", "error": str(e)}
# 6) 解析 Expo 回执,必要时停用 token
try:
data_list = (expo_res or {}).get("data") or []
if data_list and isinstance(data_list, list):
first = data_list[0] or {}
if first.get("status") == "error":
details = first.get("details") or {}
err = str(details.get("error") or first.get("message") or "expo_error")
log.status = "failed"
log.error = err
if "DeviceNotRegistered" in err:
token.is_active = False
await session.commit()
return {"status": "failed", "expo": expo_res}
except Exception:
# 忽略解析异常,继续按成功处理
pass
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
await session.commit()
return {"status": "sent", "expo": expo_res}
@shared_task(name="tasks.push.send_scheduled")
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
"""
ETA 发送任务:发送某用户某天第 slot 条推送。
"""
d = date.fromisoformat(str(local_date))
return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index)))

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from celery import Celery from celery import Celery
from celery.schedules import crontab
from app.core.config import get_settings from app.core.config import get_settings
@@ -38,6 +39,20 @@ celery_app.conf.update(
task_default_routing_key=f"{prefix}:celery", task_default_routing_key=f"{prefix}:celery",
) )
# 自动发现任务app/tasks 下的 shared_task # 定时任务Celery Beat
celery_app.autodiscover_tasks(["app.tasks"]) # 说明:
# - 每天运行一次“生成推送计划”,为所有开启每日提醒的用户生成当天/明天的随机抖动时间点,并投递 ETA 发送任务
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
celery_app.conf.timezone = "UTC"
celery_app.conf.beat_schedule = {
"push-generate-daily-schedule": {
"task": "tasks.push.generate_daily_schedule",
"schedule": crontab(minute=10, hour=0),
"kwargs": {"max_users": 5000},
"options": {"queue": f"{prefix}:celery"},
}
}
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)
celery_app.autodiscover_tasks(["app"])

View File

@@ -0,0 +1,88 @@
#!/bin/sh
set -eu
# 容器入口:
# - 默认只启动 API与原 Dockerfile 行为一致)
# - 如需测试“定时推送”,可额外启动 Celery Worker + Beat
#
# 环境变量:
# - START_API=1|0默认 1
# - START_WORKER=1|0默认 0
# - START_BEAT=1|0默认 0
# - START_ALL=1等价于 START_WORKER=1 + START_BEAT=1
# - HOST / PORTAPI 监听地址,默认 0.0.0.0:8000
log() {
echo "[entrypoint] $*"
}
START_API="${START_API:-1}"
START_WORKER="${START_WORKER:-0}"
START_BEAT="${START_BEAT:-0}"
if [ "${START_ALL:-0}" = "1" ]; then
START_WORKER="1"
START_BEAT="1"
fi
# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker。
# 说明:
# - 单容器模式:若启动了 APISTART_API=1且同时启用了 Beat则自动补齐 Worker避免误配导致“只排程不执行”。
# - 多容器模式:允许单独启动 beat 容器START_API=0, START_BEAT=1不做自动补齐。
if [ "$START_API" = "1" ] && [ "$START_BEAT" = "1" ] && [ "$START_WORKER" != "1" ]; then
log "提示:已启用 START_BEAT=1且 START_API=1自动同时启用 START_WORKER=1否则队列无人执行。"
START_WORKER="1"
fi
PIDS=""
stop_children() {
# 温和退出
for pid in $PIDS; do
kill -TERM "$pid" >/dev/null 2>&1 || true
done
}
on_term() {
log "收到退出信号,正在停止子进程..."
stop_children
# 等待子进程退出,避免残留
wait >/dev/null 2>&1 || true
exit 0
}
trap on_term INT TERM
if [ "$START_WORKER" = "1" ]; then
log "启动 Celery Workercelery -A app.worker:celery_app worker -l info"
celery -A app.worker:celery_app worker -l info &
PIDS="$PIDS $!"
fi
if [ "$START_BEAT" = "1" ]; then
log "启动 Celery Beatcelery -A app.worker:celery_app beat -l info"
celery -A app.worker:celery_app beat -l info &
PIDS="$PIDS $!"
fi
if [ "$START_API" = "1" ]; then
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8000}"
log "启动 APIuvicorn app.main:app --host $HOST --port $PORT"
uvicorn app.main:app --host "$HOST" --port "$PORT" &
API_PID="$!"
PIDS="$PIDS $API_PID"
# 以 API 生命周期为准API 退出则容器退出,并清理其他进程
wait "$API_PID"
CODE="$?"
log "API 已退出code=$CODE),正在停止其他进程..."
stop_children
wait >/dev/null 2>&1 || true
exit "$CODE"
fi
# 未启动 API就阻塞等待其他进程一般用于仅跑 worker/beat 的容器)
log "未启动 API等待后台进程..."
wait

View File

@@ -49,3 +49,17 @@ CELERY_BROKER_URL=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
# 推送Expo # 推送Expo
# EXPO_ACCESS_TOKEN= # EXPO_ACCESS_TOKEN=
# 法务协议链接(由后端统一托管并下发给客户端)
# 说明:
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
# - 不配置时:后端会自动使用内置协议页面(/v1/legal/privacy、/v1/legal/terms保证点击有内容
# - 线上建议配置为你们的官网/静态站点 HTTPS 链接,便于独立更新
#
# 可选英文EN
# LEGAL_PRIVACY_URL_EN=
# LEGAL_TERMS_URL_EN=
#
# 可选繁体TC
# LEGAL_PRIVACY_URL_TC=
# LEGAL_TERMS_URL_TC=

View File

@@ -7,28 +7,38 @@ set -euo pipefail
# - 自动启动 uvicorn默认开启 --reload # - 自动启动 uvicorn默认开启 --reload
# #
# 用法示例: # 用法示例:
# ./run.sh # 默认 host=0.0.0.0 port=8000 env=dev reload=on # ./run.sh # 默认一键启动API + Celery Worker + Celery Beat
# ./run.sh --env prod # 使用 .env.prod若存在且可被 source # ./run.sh --env prod # 使用 .env.prod若存在且可被 source
# ./run.sh --port 9000 # 改端口 # ./run.sh --port 9000 # 改端口
# ./run.sh --no-reload # 关闭热更新 # ./run.sh --no-reload # 关闭热更新
# ./run.sh --with-worker --with-beat # 同时启动 Celery Worker + Beat用于定时推送
# ./run.sh --all # 等价于 --with-worker --with-beat
# START_ALL=1 ./run.sh # 用环境变量一键启动(适合写到脚本/别名里)
# ./run.sh --api-only # 只启动 API不启动 Worker/Beat
# ./run.sh --install-only # 只安装依赖,不启动 # ./run.sh --install-only # 只安装依赖,不启动
usage() { usage() {
cat <<'EOF' cat <<'EOF'
用法: 用法:
./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--skip-install] [--install-only] ./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--api-only] [--with-worker] [--with-beat] [--all] [--skip-install] [--install-only]
参数: 参数:
--env dev|prod 优先尝试加载 .env.dev 或 .env.prod如果存在 --env dev|prod 优先尝试加载 .env.dev 或 .env.prod如果存在
--host <host> uvicorn host默认 0.0.0.0 --host <host> uvicorn host默认 0.0.0.0
--port <port> uvicorn port默认 8000 --port <port> uvicorn port默认 8000
--no-reload 关闭 uvicorn --reload --no-reload 关闭 uvicorn --reload
--api-only 只启动 API不启动 Worker/Beat
--with-worker 同时启动 Celery Worker处理异步/ETA 任务)
--with-beat 同时启动 Celery Beat定时调度例如每日生成推送排程
--all 同时启动 Worker + Beat等价于 --with-worker --with-beat
--skip-install 跳过依赖安装(默认会安装/更新 requirements.txt --skip-install 跳过依赖安装(默认会安装/更新 requirements.txt
--install-only 只安装依赖,不启动服务 --install-only 只安装依赖,不启动服务
-h, --help 显示帮助 -h, --help 显示帮助
说明: 说明:
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。 - 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
- 仅启动 API 并不会生成 `push_send_log`;要测试“定时推送”,需要 Beat 调度 `tasks.push.generate_daily_schedule`,并由 Worker 执行后续 ETA 任务。
- 也可以用环境变量一键启动START_ALL=1 ./run.sh
- 启动后访问: - 启动后访问:
/healthz 健康检查 /healthz 健康检查
/docs OpenAPI 文档 /docs OpenAPI 文档
@@ -45,6 +55,10 @@ PORT="8000"
RELOAD="1" RELOAD="1"
SKIP_INSTALL="0" SKIP_INSTALL="0"
INSTALL_ONLY="0" INSTALL_ONLY="0"
API_ONLY="0"
# 默认一键启动满足“bash run.sh 就全部启动”)
WITH_WORKER="1"
WITH_BEAT="1"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -64,6 +78,25 @@ while [[ $# -gt 0 ]]; do
RELOAD="0" RELOAD="0"
shift 1 shift 1
;; ;;
--api-only)
API_ONLY="1"
WITH_WORKER="0"
WITH_BEAT="0"
shift 1
;;
--with-worker)
WITH_WORKER="1"
shift 1
;;
--with-beat)
WITH_BEAT="1"
shift 1
;;
--all)
WITH_WORKER="1"
WITH_BEAT="1"
shift 1
;;
--skip-install) --skip-install)
SKIP_INSTALL="1" SKIP_INSTALL="1"
shift 1 shift 1
@@ -99,6 +132,28 @@ if [[ -f "$ENV_FILE" ]]; then
set +a set +a
fi fi
# 允许通过环境变量一键开启(适合写到别名/CI 脚本里)
if [[ "${START_ALL:-0}" == "1" ]]; then
WITH_WORKER="1"
WITH_BEAT="1"
fi
# 允许通过环境变量强制只启动 API
if [[ "${START_API_ONLY:-0}" == "1" ]]; then
API_ONLY="1"
WITH_WORKER="0"
WITH_BEAT="0"
fi
# 让 API/Celery 统一使用同一个 APP_ENV影响 Redis key 前缀、定时任务配置等)
export APP_ENV="${APP_ENV:-$ENV_NAME}"
# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker这里自动补齐避免误用。
if [[ "$WITH_BEAT" == "1" && "$WITH_WORKER" == "0" ]]; then
echo "提示:已启用 --with-beat自动同时启用 --with-worker否则队列无人执行。"
WITH_WORKER="1"
fi
# 选择 python 命令(优先 python3 # 选择 python 命令(优先 python3
PY_BIN="" PY_BIN=""
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
@@ -140,6 +195,45 @@ if [[ "$RELOAD" == "1" ]]; then
UVICORN_ARGS+=(--reload) UVICORN_ARGS+=(--reload)
fi fi
echo "启动服务uvicorn ${UVICORN_ARGS[*]}" if [[ "$WITH_WORKER" == "0" && "$WITH_BEAT" == "0" ]]; then
exec uvicorn "${UVICORN_ARGS[@]}" echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
exec uvicorn "${UVICORN_ARGS[@]}"
fi
PIDS=()
cleanup() {
# 避免重复清理导致脚本退出码被覆盖
set +e
if [[ ${#PIDS[@]} -gt 0 ]]; then
echo ""
echo "正在停止后台进程..."
# 先尝试温和退出
for pid in "${PIDS[@]}"; do
kill -TERM "$pid" >/dev/null 2>&1 || true
done
# 等待一点时间,再强制杀掉仍存活的(防止残留)
sleep 1
for pid in "${PIDS[@]}"; do
kill -KILL "$pid" >/dev/null 2>&1 || true
done
fi
}
trap cleanup EXIT INT TERM
if [[ "$WITH_WORKER" == "1" ]]; then
echo "启动 Celery Workercelery -A app.worker:celery_app worker -l info"
celery -A app.worker:celery_app worker -l info &
PIDS+=("$!")
fi
if [[ "$WITH_BEAT" == "1" ]]; then
echo "启动 Celery Beatcelery -A app.worker:celery_app beat -l info"
celery -A app.worker:celery_app beat -l info &
PIDS+=("$!")
fi
echo "启动服务uvicorn ${UVICORN_ARGS[*]}"
uvicorn "${UVICORN_ARGS[@]}"

View File

@@ -0,0 +1,72 @@
import os
import unittest
class TestLegalLinksLangResolution(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
# 让 Settings 能顺利构造(避免因必填 env 缺失导致导入时报错)
os.environ.setdefault("DATABASE_URL", "mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev?charset=utf8mb4")
os.environ.setdefault("REDIS_URL", "redis://:p@127.0.0.1:6379/0")
os.environ.setdefault("CELERY_BROKER_URL", "redis://:p@127.0.0.1:6379/0")
def setUp(self) -> None:
# 每个用例都清理 settings 缓存,避免环境变量修改不生效
from app.core.config import get_settings
get_settings.cache_clear()
def test_resolve_lang_default_en(self) -> None:
from app.api.v1.legal import _resolve_lang
self.assertEqual(_resolve_lang(None), "en")
self.assertEqual(_resolve_lang(""), "en")
def test_resolve_lang_tc_for_zh(self) -> None:
from app.api.v1.legal import _resolve_lang
self.assertEqual(_resolve_lang("zh"), "tc")
self.assertEqual(_resolve_lang("zh-Hant"), "tc")
self.assertEqual(_resolve_lang("zh-TW"), "tc")
self.assertEqual(_resolve_lang("zh-HK"), "tc")
self.assertEqual(_resolve_lang("tc"), "tc")
def test_pick_urls_fallback_to_en(self) -> None:
# 不配置 tc 链接时,应回退到 en
os.environ["LEGAL_PRIVACY_URL_EN"] = "https://example.com/en-privacy"
os.environ["LEGAL_TERMS_URL_EN"] = "https://example.com/en-terms"
os.environ.pop("LEGAL_PRIVACY_URL_TC", None)
os.environ.pop("LEGAL_TERMS_URL_TC", None)
from app.core.config import get_settings
get_settings.cache_clear()
from app.api.v1.legal import _pick_urls
privacy, terms, resolved = _pick_urls("tc")
self.assertEqual(privacy, "https://example.com/en-privacy")
self.assertEqual(terms, "https://example.com/en-terms")
self.assertEqual(resolved, "en")
def test_pick_urls_tc_when_configured(self) -> None:
os.environ["LEGAL_PRIVACY_URL_EN"] = "https://example.com/en-privacy"
os.environ["LEGAL_TERMS_URL_EN"] = "https://example.com/en-terms"
os.environ["LEGAL_PRIVACY_URL_TC"] = "https://example.com/tc-privacy"
os.environ["LEGAL_TERMS_URL_TC"] = "https://example.com/tc-terms"
from app.core.config import get_settings
get_settings.cache_clear()
from app.api.v1.legal import _pick_urls
privacy, terms, resolved = _pick_urls("tc")
self.assertEqual(privacy, "https://example.com/tc-privacy")
self.assertEqual(terms, "https://example.com/tc-terms")
self.assertEqual(resolved, "tc")
if __name__ == "__main__":
unittest.main()

169
spec_kit/App Push/plan.md Normal file
View File

@@ -0,0 +1,169 @@
# App Push每日提醒推送客户端 + 后端Plan
> 阶段技术计划plan
>
> 依据:`spec_kit/App Push/spec.md`
---
## 1. 总体思路
本期以 **Expo PushExpo Push Token + Expo Push Service** 为推送通道,实现“每日提醒”闭环:
- **客户端**:负责
- 生成/持久化 `client_user_id`UUID
- 引导用户设置每日次数05可跳过
- 申请通知权限、获取 Expo Push Token、并上报后端
- 在个人主页的“每日提醒”弹窗修改次数或关闭,并同步到后端
- 上报 `timezone`IANA`locale`(用于文案语言)
- **后端**:负责
- 保存 token 绑定与用户推送偏好
- 每日按用户时区在 9:0024:00 窗口内生成 \(N\in[0,5]\) 个随机抖动时间点
- 在这些时间点向用户发送推送(使用后端 `@overview.md` 约定的个性化推荐模板/文案策略)
- 幂等防重复(同一用户同一天同一序号只发一次)
---
## 2. 客户端设计
### 2.1 UUIDclient_user_id
- 复用 `spec_kit/Client User Identity/spec.md` 的结论:
- 默认 UUID v4
- 本地稳定持久化
- 获取时机:
- App 启动即确保已生成(便于后续任何上报都可带上)
### 2.2 Push 权限与 token 获取策略
- 在 Onboarding 的“每日提醒”设置页(或 push 引导页)进行权限申请:
- 先说明 → 再弹系统权限框
- token 获取与上报触发:
- 权限 granted 后立即获取 token 并上报
- 后续冷启动时可“补偿上报”(避免首轮失败导致后端缺 token
### 2.3 每日提醒设置05 次)
#### Onboarding
- 新增或复用一个 Onboarding 步骤:
- 05 次选择
- “跳过”按钮(跳过不阻塞)
- 当用户选择 `times_per_day > 0`
- 引导用户开启系统通知权限
- 成功与否都进入主功能
#### 个人主页弹窗Daily Reminder
- 现有弹窗:
- 次数 +/-(限制 05
- 开关(关闭等价 `times_per_day=0`
- 权限 denied给出清晰提示
- 修复:移除/关闭“测试模式强制无权限”逻辑(该逻辑会导致永远表现为无法开启)
### 2.4 客户端与后端接口调用
- 新增 `client/src/services/pushApi.ts`(参考现有 `legalApi.ts` / `recoApi.ts` 的风格),并使用统一 HTTP 封装 `client/src/utils/http.ts`
- 接口调用点:
- register拿到 token 后
- preferences用户完成选择或在个人主页修改后
---
## 3. 后端设计
### 3.1 数据模型(最小可用)
1) `push_tokens`
- 唯一键:`env + app_id + push_token`
- 字段:
- `client_user_id`UUID string
- `platform`ios/android
- `push_token`
- `app_id`
- `env`
- `is_active`
- `last_seen_at`
2) `push_preferences`
- 主键:`client_user_id`(或加 `env + app_id` 做隔离)
- 字段:
- `enabled`
- `times_per_day`05
- `timezone`IANA来自客户端
- `locale`(用于文案选择)
- `updated_at`
3) `push_send_log`(幂等防重复)
- 目的:保证“同一用户同一天第 k 条只发一次”
- 唯一键:
- `client_user_id + local_date + slot_index`slot_index=1..times_per_day
- 字段:
- `scheduled_at`(用户时区的时间点)
- `sent_at`
- `status`scheduled/sent/failed
- `error`(可选)
> 注:不直接对数据库执行破坏性操作;通过迁移脚本落地表结构,执行迁移需你显式允许后才进行。
### 3.2 API
`spec_kit/App Push/spec.md`
- `POST /v1/push/register`
- `PUT /v1/push/preferences`
- `GET /v1/push/preferences`
- `POST /v1/push/test`(仅 dev
### 3.3 推送发送器Expo
- 使用 `https://exp.host/--/api/v2/push/send`
- 最小 payload
- `to`Expo Push Token
- `title/body`:来自个性化推荐模板
- `data`:深链路参数(可选)
- 失败处理:
- 对 “DeviceNotRegistered”等不可恢复错误将 token 标记为 inactive
- 记录失败原因到 `push_send_log`
### 3.4 定时任务(每日 924随机抖动
策略:**每日“生成当天计划 + 分发 ETA 任务”**(推荐)
- 每日固定时刻(例如 UTC 00:10 或服务器本地时间某个点)执行一次“生成计划任务”:
- 对每个 `enabled && times_per_day>0` 的用户:
- 将用户时区当天的 9:0024:00 转换为 UTC
- 随机生成 \(N\) 个时间点:
- 均匀切分窗口为 \(N\) 个区间
- 每个区间内随机选一个时间(抖动)
- 对每个 slot 写入 `push_send_log`(唯一键保证幂等)
- 再投递 Celery ETA 任务,到点调用“发送 push”
这样可以避免 worker 长时间 sleep也便于观察“今天会推哪些”。
---
## 4. 个性化推荐模板(后端 @overview.md
推送文案生成使用后端推荐模块中 “Push 场景模板/策略”:
- 输入:`client_user_id`、(可选)用户画像/行为统计
- 输出:`title/body`(多语言)
- 风险控制Push 场景默认“降个性化/降风险”,避免敏感/医疗类文案(遵守后端已定义的安全规则)
> plan 阶段仅明确“调用点与输入输出”,具体实现复用后端推荐模块现有能力,避免重复逻辑。
---
## 5. 测试与验收(落地检查清单)
- 客户端:
- 首次进入可选择次数/跳过
- 权限 granted 时能拿到 Expo token 并上报
- 设置页改次数/关闭会调用 preferences 接口并持久化
- 后端:
- register/preferences 接口幂等可重试
- test 接口可在 dev 环境立刻推送到真机
- 每日计划生成不会重复排程(`push_send_log` 唯一键)
- token 失效可自动停用

220
spec_kit/App Push/spec.md Normal file
View File

@@ -0,0 +1,220 @@
# App Push每日提醒推送客户端 + 后端Spec
> 阶段高层规范spec
>
> 目标:实现“每日提醒”推送闭环:用户在首次进入 Onboarding 可选择每天推送次数05可跳过并可在个人主页的“每日提醒”弹窗随时调整次数或关闭客户端使用首次生成的 UUID 作为用户标识与后端关联;后端按用户配置定时下发推送。
---
## 1. 背景与动机(摘要)
当前客户端已有 Push 引导页(可跳过)与“每日提醒”设置入口,但尚未形成完整闭环:
- 需要一个稳定的匿名用户标识UUID将“提醒配置”与“推送 token”绑定到后端
- 需要后端能够按用户选择的每日次数05进行定时推送
- 本期不追求高级推送能力(富媒体、复杂分群、到达率分析等)
---
## 2. 目标Goals
- **G1Onboarding 选择**:用户首次进入 Onboarding 可设置“每日提醒次数”05可跳过且不阻塞进入主功能。
- **G2设置页可改可关**用户可在个人主页的“每日提醒”弹窗调整次数05或关闭推送。
- **G3UUID 关联**:客户端首次进入生成 `client_user_id`UUID用于与后端关联用户配置与推送 token`spec_kit/Client User Identity/spec.md`)。
- **G4推送闭环**:客户端拿到推送权限与 Push Token 后上报后端;后端存储并按计划每日推送。
- **G5幂等与可恢复**重复上报、token 轮换、用户反复开关不应导致重复推送或“僵尸任务”。
- **G6多语言**:推送文案至少支持当前客户端语言体系(`zh-CN/en/es/pt/zh-TW`),或以安全默认语言回退。
---
## 3. 非目标Non-goals
- 不接入高级推送能力(富媒体、通知分类/动作按钮、复杂分群、A/B 实验、到达率看板等)。
- 不引入账号体系与“多设备同人合并”策略(未来可基于 `account_id` 扩展)。
- 不在本阶段做精细化内容策略(例如千人千面的复杂推荐推送);仅保证“按次数定时发送”与文案安全合规。
---
## 4. 技术选型(高层)
### 4.1 客户端
- 继续使用 **Expo**`expo-notifications`
- Push 权限申请:通过引导页解释后再触发系统弹窗(降低拒绝率)
- Token 获取:使用 Expo Push Token后续计划阶段细化获取与上报时机
### 4.2 后端
- 推送通道:**Expo Push Service**(与客户端 `expo-notifications` 配套)
- 定时任务使用现有后端技术栈约定Celery + Redis / 或等价调度器),每日按用户时区/策略触发推送
> 说明:若未来需要更高上限(自建 APNs/FCM 直连),可在后续模块升级,不影响本期 API 字段语义(`client_user_id`、`push_token`、偏好配置)的大框架。
---
## 5. 用户流程User Flows
### 5.1 首次进入Onboarding
1. App 首次启动 → 进入 Onboarding 流程
2. 在 Onboarding 某一页提供“每日提醒次数”选择05
- 0 表示不接收每日提醒(等同关闭)
- 可点击“跳过”(不设置/不打扰)
3. 若用户选择次数 > 0
- 展示 Push 引导说明页
- 用户点击“立即开启”触发系统权限申请
4. 无论是否开启成功,均可进入主功能(不阻塞)
### 5.2 个人主页(每日提醒弹窗)
- 用户可:
- 调整每日次数05
- 开启/关闭提醒(关闭等价次数=0
- 若系统权限为 denied提示用户前往系统设置开启不做强制跳转要求按平台能力实现
### 5.3 后端推送执行
- 后端按用户配置与时区,在每日窗口内发送 \(N\) 次推送(\(N\in[0,5]\)
- 若用户关闭或次数=0不再推送
---
## 6. 数据与持久化(高层)
### 6.1 客户端本地存储(最小集合)
- `client_user_id`: stringUUID稳定持久化见用户标识 spec
- `push.permission_state`: `unknown | granted | denied`(用于 UI 显示与引导)
- `daily_reminder.enabled`: boolean可选或由次数是否为 0 推导)
- `daily_reminder.times_per_day`: number05
- `daily_reminder.updated_at`: ISO string可选用于排障/幂等)
### 6.2 后端持久化(逻辑约束)
最小需要表达两类信息:
1) **设备/Token 绑定**
- `client_user_id`
- `platform`ios/android
- `push_token`Expo Push Token
- `app_id`bundle id / package name
- `env`dev/prod
- `last_seen_at`
- `is_active`
2) **用户推送偏好**
- `client_user_id`
- `enabled`
- `times_per_day`05
- `timezone`(建议 IANA`Asia/Shanghai`;若拿不到则回退为服务器默认策略)
- `locale`(用于推送文案语言选择;可从客户端上报或推送时推断)
- `updated_at`
---
## 7. API 契约(高层)
> 说明:路由名可在 plan 阶段对齐现有服务结构;此处先固定“字段语义 + 幂等行为”。
### 7.1 注册/更新 Push Token幂等
- `POST /v1/push/register`
- 请求体(最小集,继承用户标识 spec
- `client_user_id`: stringUUID
- `platform`: `"ios" | "android"`
- `push_token`: stringExpo Push Token
- `app_id`: string
- `env`: `"dev" | "prod"`
- `device_meta`(可选):`{ model, os_version, app_version, locale, timezone }`
- 行为:
- 幂等:重复上报同一 token 不产生多条“有效绑定”
- token 变更:同一 `client_user_id` 上报新 token 后,后端应更新“当前有效 token”
### 7.2 设置每日提醒偏好(幂等)
- `PUT /v1/push/preferences`
- 请求体:
- `client_user_id`: string
- `enabled`: boolean
- `times_per_day`: number05若 enabled=false 则可强制视为 0
- `timezone`: string可选
- `locale`: string可选
- 行为:
- 幂等:相同配置重复提交不改变结果
- `enabled=false``times_per_day=0`:必须停止后续推送(不再产生新的发送任务)
### 7.3 查询当前偏好(可选但建议)
- `GET /v1/push/preferences?client_user_id=...`
- 返回:
- `enabled`
- `times_per_day`
- `timezone`
- `locale`
- `updated_at`
### 7.4 立即测试推送(仅 dev可选
- `POST /v1/push/test`
- 请求体:
- `client_user_id`
- `title` / `body`(可选)
- 用途联调排障token 绑定、证书/通道、Expo 配置)
---
## 8. 推送策略(高层)
### 8.1 发送次数与窗口
- 每日发送次数05
- 建议定义“允许发送的时间窗口”(例如 09:0021:00避免深夜打扰
-`times_per_day > 0` 时,在窗口内生成 \(N\) 个时间点并发送
> 时间点生成策略(均匀分布/固定时刻/随机抖动)在 plan 阶段确定;本期只要求“次数正确、用户可控、不会超发”。
### 8.2 文案来源与语言
- 文案可先采用后端配置的模板(按 `locale` 选择,失败回退 `en``zh-CN`
- 若未来要接入推荐系统,可在后续迭代按用户画像生成更个性化文案(不在本期范围)
---
## 9. 边界场景与处理原则
- **用户跳过 Onboarding**:不强制开启;可在个人主页再次设置。
- **系统权限 denied**:客户端提示引导去系统设置;后端保存偏好但不保证可推送(无有效 token 时不发送)。
- **token 缺失/过期**:后端发送失败时应标记 token 为不可用,并等待客户端下次上报更新。
- **重复开关/改次数**:后端必须幂等更新,避免同一用户一天内重复排程导致超发。
- **环境隔离**dev/prod 的 token 与配置必须隔离(`env + app_id` 维度)。
- **卸载/重装**`client_user_id` 可能变化;视为新用户实例(符合匿名策略)。
---
## 10. 验收标准Acceptance Criteria
- 首次进入 Onboarding
- 用户可选择每日次数05或跳过
- 选择后不阻塞进入主功能
- 个人主页每日提醒弹窗:
- 可设置次数05与关闭
- 权限 denied 时有明确提示
- 客户端:
- `client_user_id` 稳定持久化
- 在获得权限与 token 后能上报后端(幂等)
- 修改偏好会同步到后端(幂等)
- 后端:
- 能保存 token 与偏好
- 能按用户配置每日推送(次数正确、不会超发)
- token 失效可被识别并停止对失效 token 推送
---
## 11. 待确认问题清单(进入 plan/tasks 前必须确认)
1. **“05 次”的含义**:是否严格表示“每天发送 05 条通知”(看起来是),还是“提醒强度档位”?
2. **发送时间策略**:默认窗口与时间点生成方式(固定时刻 vs 均匀分布 vs 随机抖动)选哪一种?
3. **时区来源**:以客户端上报的 IANA 时区为准吗?若缺失回退到什么策略?
4. **推送文案**:本期文案是否完全由后端模板控制(便于随时调整),还是前端上报“文案 key/参数”由后端拼装?
5. **Push 允许发送的静默规则**:是否需要“勿扰时间段/睡眠模式”开关(例如 22:0008:00 不推)?

127
spec_kit/App Push/tasks.md Normal file
View File

@@ -0,0 +1,127 @@
# App Push每日提醒推送客户端 + 后端Tasks
> 阶段任务清单tasks
>
> 依赖:`spec_kit/App Push/spec.md`、`spec_kit/App Push/plan.md`
---
## 0. 约束与共识(本期已确认)
- 每日次数:**05 条/天**
- 发送窗口:**9:0024:00按客户端上报时区**
- 时间点:窗口内 **随机抖动**
- 勿扰:**不做**
- 文案:使用后端推荐模块的 **Push 场景个性化模板(降风险)**
---
## 1. 客户端Expo RN
### 1.1 UUIDclient_user_id
- [ ]`client/src/storage/appStorage.ts`(或新模块)实现:
- `getOrCreateClientUserId(): Promise<string>`
- 首次生成 UUID 并持久化,后续复用
### 1.2 Push 权限与 token
- [ ] 接入获取 Expo Push Token 的封装(例如 `src/features/push/`
- 获取权限状态
- 请求权限
- 获取 Expo Push Token
- [ ] 在 Onboarding / push 引导完成后:
- granted 时:获取 token + 调 `register`
- 未 granted仅保存本地设置不阻塞进入主功能
### 1.3 “每日提醒次数”入口
- [ ] Onboarding新增/复用一个步骤页面
- 选择 05 次0 表示关闭)
- 支持跳过
- 完成后写本地存储,并调用后端 `preferences`
- [ ] 个人主页弹窗:
- 修复“测试模式强制无权限”的逻辑
- 次数限制 05关闭等价 0
- denied 时提示去系统设置
- 修改后写本地存储,并调用后端 `preferences`
### 1.4 接口封装
- [ ] 新增 `client/src/services/pushApi.ts`
- `registerPushToken(...)`
- `setPushPreferences(...)`
- `getPushPreferences(...)`(可选)
- [ ] 统一使用 `client/src/utils/http.ts` 的请求封装
### 1.5 联调开关与日志
- [ ] 开发环境输出必要日志(不打印敏感信息):
- client_user_id 生成结果
- 权限状态与 token 是否获取成功
- register/preferences 的请求是否成功与错误原因
---
## 2. 后端FastAPI
### 2.1 数据模型与迁移
- [ ] 新增表(或等价模型):
- `push_tokens`
- `push_preferences`
- `push_send_log`(幂等防重复)
- [ ] 增加迁移脚本Alembic 或项目现有迁移机制)
> 注意:不执行任何破坏性数据库操作;运行迁移前若涉及真实库,需要你明确回复“允许操作数据库”。
### 2.2 API
- [ ] `POST /v1/push/register`
- 幂等:`env + app_id + push_token` 唯一
- 更新 `client_user_id` 归属与 `last_seen_at`
- [ ] `PUT /v1/push/preferences`
- 校验 `times_per_day` ∈ [0,5]
- `enabled=false``times_per_day=0` 时停止后续推送
- [ ] `GET /v1/push/preferences`
- 返回当前偏好与更新时间
- [ ] `POST /v1/push/test`(仅 dev
- 立即向该用户发送一条测试推送(用于真机联调)
### 2.3 Expo 推送发送器
- [ ] 实现 `send_expo_push(token, title, body, data?)`
- 处理 Expo 返回错误并对不可恢复错误停用 token
- 记录发送结果到 `push_send_log`
### 2.4 推送文案(推荐模板)
- [ ] 在推送任务中调用后端推荐模块的 Push 场景模板:
- 输入:`client_user_id`、语言/时区(可选)
- 输出:`title/body`
- 默认“降个性化/降风险”
---
## 3. 定时任务(每日计划 + ETA 发送)
- [ ] 每日“计划生成任务”
- 扫描 `enabled && times_per_day>0` 用户
- 按用户时区在 9:0024:00 生成 N 个随机抖动时间点
- 写入 `push_send_log`(唯一键保证幂等)
- 投递 ETA 发送任务(或按项目现有任务系统实现)
- [ ] ETA “发送任务”
- 拉取当次发送所需 token/偏好
- 生成文案(推荐模板)
- 调用 Expo push 发送
- 更新 `push_send_log` 状态
---
## 4. 验收与回归
- [ ] iOS 真机权限申请、token 获取、test 推送可达
- [ ] Android 真机权限申请、token 获取、test 推送可达
- [ ] 修改次数:后端计划生成正确(一天内不超发)
- [ ] 关闭:后端停止后续推送(不再生成计划/不再发送)

View File

@@ -0,0 +1,24 @@
# Daily Widget Reco补充说明 / Overflow
## 1. 关键配置
- **App Group suiteName**`group.com.damer.mindfulness`
- **后端鉴权**:无
- **多语言**:仅 `en / tc`
## 2. 共享存储 KeyApp ↔ Widget
- `widget.config.v1`
- 字段:`schema_version=1``saved_at``apiBaseUrl`
- `widget.userProfile.v1_2`
- 字段:`schema_version=1``saved_at``user_profile`(结构对齐 `UserProfileV1_2`
- `widget.dailyReco.v1`
- 字段:`schema_version=1``saved_at``day_key`(本地日 `YYYY-MM-DD`)、`lang``item{content_id,text}``source`
## 3. 更新策略(双通道)
- **Widget 主动拉取**:缓存过期时请求 `POST /v1/reco/widget`,写回 `widget.dailyReco.v1`,并使用 `TimelinePolicy.after` 设定次日刷新
- **App 辅助刷新**
- 启动与回到前台时调用 `ensureDailyWidgetRecoUpToDate`
- 成功写入缓存后调用 `WidgetCenter.reloadAllTimelines()`(系统仍可能延迟)

View File

@@ -0,0 +1,176 @@
# Daily Widget Reco技术计划
> 对应规范:`spec_kit/Daily Widget Reco/spec.md`
>
> 已确认输入(来自澄清):
>
> - 更新责任:**App 与 Widget 都需要**双通道Widget 主动拉取 + App 辅助刷新)
> - App Group suiteName`group.com.damer.mindfulness`
> - 后端鉴权:不需要
> - baseURL由 **App 写入共享区** 提供给 Widget
> - “每日”口径:用户本地时区
> - 画像来源App 写入共享区Onboarding 完成后写入)
---
## 1. 计划目标
- 打通 `POST /v1/reco/widget`,让客户端与 Widget 都能获取每日推荐Top1
- 通过 App Group 共享存储,保证 **App 与 Widget 当天展示一致**
- 实现“每日更新(尽力而为)”:用户添加小组件后,即使不打开 App也能依赖 WidgetKit Timeline 在每日范围内刷新。
- 完整回退链路:今日缓存 → 最近缓存 → 兜底文案;任何失败不 crash、不阻塞主流程。
---
## 2. 总体方案(双通道更新)
### 2.1 核心结论
- **Widget 主动拉取**是满足“添加小组件后每日更新”的必要条件(仅靠 App 前台触发无法覆盖用户不打开 App 的情况)。
- 同时保留 **App 辅助拉取与 reload**
- App 启动/进入前台/画像更新后可主动拉取并写缓存,提升即时性与一致性。
- App 在写入共享缓存后调用 `WidgetCenter.reloadAllTimelines()`,加速 Widget 读取到新数据(系统仍可能延迟)。
### 2.2 数据流(高层)
- AppRN
- 写入共享配置(`apiBaseUrl` 等)→ 写入共享画像 →(可选)拉取 `widget` 推荐 → 写入共享每日缓存 → reload Widget
- WidgetSwift
- 读取共享每日缓存,若过期则读取共享配置+画像并请求后端 → 写入共享缓存 → 输出 timeline
---
## 3. 客户端React Native / Expo改动点
### 3.1 新增 Widget 场景 API 封装
-`client/src/services/recoApi.ts` 新增:
- `fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult>`
- Header `Accept-Language` 逻辑沿用 Feed`zh* -> tc` else `en`
- path`/v1/reco/widget`
### 3.2 共享存储:新增 App Group 写入能力
> 目标RN 侧将必要数据写入 `UserDefaults(suiteName: "group.com.damer.mindfulness")`,供 Widget 读取。
建议实现路径(两种任选其一,按当前工程实际选型落地):
- 方案 A推荐引入一个“App Group UserDefaults 读写”的 RN 原生桥/插件Swift/ObjC 模块 + JS 封装)。
- 方案 B若项目已存在可用能力例如已接入 `react-native-shared-group-preferences` 或自研模块),直接复用并统一 key。
需要写入的共享 key与 spec 对齐):
- `widget.config.v1`:包含 `apiBaseUrl`(以及未来灰度字段)
- `widget.userProfile.v1_2`用户画像快照JSON
- `widget.dailyReco.v1`每日推荐缓存JSON
### 3.3 App 侧写入与刷新触发时机
- **画像写入**
- Onboarding 完成后(或画像更新后)写入 `widget.userProfile.v1_2`
- **配置写入**
- App 启动时(或 `API_BASE_URL` 计算完成后)写入 `widget.config.v1`
- **主动拉取每日推荐(可选但建议)**
- App 启动或进入前台时:
- 读取共享 `widget.dailyReco.v1`,若 `day_key` 不是今天则调用 `fetchRecoWidget` 拉取 Top1
- 成功写入共享缓存,失败不影响 UI
- **触发 Widget 刷新**
- 当 App 写入 `widget.dailyReco.v1` 成功后,调用 `WidgetCenter.reloadAllTimelines()`(通过原生桥触发)
> day_key 计算:以用户本地时区生成 `YYYY-MM-DD`(例如 `2026-02-03`)。
---
## 4. iOS WidgetSwift / WidgetKit改动点
### 4.1 共享存储读取
- 统一使用:
- `UserDefaults(suiteName: "group.com.damer.mindfulness")`
- 读取并解析:
- `widget.dailyReco.v1`
- `widget.userProfile.v1_2`
- `widget.config.v1`
解析失败必须容错:视为缺失,走回退策略。
### 4.2 Timeline 刷新策略(每日)
- `getTimeline` 流程:
1.`widget.dailyReco.v1`
2.`day_key` 为今天且文案存在 → 直接出 timeline
3. 否则尝试网络拉取(需要 config + userProfile 均存在):
- 请求 `POST {apiBaseUrl}/v1/reco/widget`
- `Accept-Language`:从缓存 lang 或系统语言映射到 `en/tc`(优先与 App 一致)
- `k=1`
4. 成功:写入共享缓存并出 timeline
5. 失败:用“最近缓存/兜底文案”出 timeline
- `policy`
- `TimelinePolicy.after(nextRefreshDate)`
- `nextRefreshDate`:下一天本地时间的一个随机刷新点(例如 00:1001:00 随机),减少集中刷新与被系统限流概率
### 4.3 网络实现注意事项
- 使用 `URLSession`,超时建议 812 秒(与 RN 对齐即可)
- 无鉴权
- 失败不抛出 crash打印有限日志或仅在 Debug
---
## 5. 共享数据结构与一致性规则
### 5.1 `widget.dailyReco.v1`v1
`spec.md` 定义字段,重点:
- `day_key`:本地日 key用户时区
- `lang``en|tc`
- `item.content_id + item.text`Widget 展示必须字段
- `source`:标记由 `app` 还是 `widget` 写入(便于排查)
### 5.2 一致性策略(同一天同一条)
规则:
- Widget 展示以 `widget.dailyReco.v1` 为准(共享缓存是单一事实来源)。
- App 若拉取到新结果,应覆盖写入共享缓存,并触发 reload保证 Widget 同步。
- 若当天出现“不同条”风险(并发写入):
- 以最后写入者为准last-write-wins
- 通过 `saved_at` + `source` 辅助排查
---
## 6. 回退策略(必须实现)
- 今日缓存存在且有效 → 展示
- 今日缓存无效但有最近缓存 → 展示最近缓存
- 无任何缓存 → 展示兜底安全文案(写死)
同时:
- 后端返回 `items=[]` 视为失败
- JSON 解析失败视为无缓存
---
## 7. 实施顺序(建议)
1. **补齐客户端 API**:新增 `fetchRecoWidget`,并在本地可通过 Postman/或 RN 调用验证返回。
2. **落地 App Group 共享读写RN → iOS**:先能写入/读取一个测试 key确保 suiteName 正确。
3. **写入共享配置与画像**:保证 Widget 侧具备发起请求所需输入。
4. **Widget 侧读取缓存并展示**:先用共享缓存驱动 UI不接网络也能跑通
5. **Widget 侧网络拉取 + 写回缓存**:实现每日更新主链路。
6. **App 辅助刷新**App 前台拉取(可选)+ 写缓存 + reloadAllTimelines。
---
## 8. 验收与测试建议
- **联调验收**
- 手动清空共享缓存 → 添加 Widget → 观察首次拉取并展示(或先展示兜底再更新)
- 修改设备日期到次日(或模拟 day_key 变化)→ 触发 `getTimeline`,确认会重新拉取
- 断网 → 应展示最近缓存/兜底文案,不崩溃
- **一致性验收**
- App 拉取后写入共享缓存Widget 在 reload 后显示同一条

Some files were not shown because too many files have changed in this diff Show More