fix:小组件- PUSH
This commit is contained in:
@@ -1,9 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
// 小组件数据同步(仅 iOS 生效;内部会判断原生模块是否可用)
|
||||
// 目的:避免“仅 Onboarding 写入一次”导致老用户小组件一直显示兜底文案
|
||||
void (async () => {
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromStorage();
|
||||
await ensureDailyWidgetRecoUpToDate({ reason: 'app_start' });
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -13,6 +26,8 @@ export default function AppLayout() {
|
||||
<Stack.Screen
|
||||
name="home"
|
||||
options={{
|
||||
// Home 页不使用系统 Header,避免 iOS 原生导航栏自带的“毛玻璃/液玻璃”材质
|
||||
headerShown: false,
|
||||
// 卡片页标题按设计留空(右上角为 icon 按钮)
|
||||
title: '',
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||
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, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
@@ -78,7 +79,7 @@ export default function HomeScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isEnglish = i18n.language?.startsWith('en');
|
||||
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
@@ -210,32 +211,6 @@ export default function HomeScreen() {
|
||||
|
||||
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(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
@@ -368,6 +343,23 @@ export default function HomeScreen() {
|
||||
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]}>
|
||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||
{item.text}
|
||||
@@ -384,12 +376,12 @@ export default function HomeScreen() {
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
{likeFilled ? (
|
||||
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
||||
<LikeFilledIcon width={35} height={36} color="#EA6969" />
|
||||
) : (
|
||||
<LikeIcon
|
||||
width={35}
|
||||
height={36}
|
||||
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
||||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -435,10 +427,12 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerRight: {
|
||||
topRight: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingRight: 10,
|
||||
zIndex: 30,
|
||||
},
|
||||
circleBtn: {
|
||||
width: 34,
|
||||
|
||||
@@ -12,7 +12,6 @@ export default function OnboardingLayout() {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
||||
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
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 { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
import {
|
||||
recordRecoFeedServed,
|
||||
setOnboardingCompleted,
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
setDailyReminderSettings,
|
||||
setUserProfileScoring,
|
||||
setRecoFeedCache,
|
||||
setPushPromptState,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
type Step =
|
||||
@@ -50,9 +55,8 @@ export default function OnboardingScreen() {
|
||||
}, [t, currentStep]);
|
||||
|
||||
async function onFinish() {
|
||||
// 请求推送权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
const pushEnabled = status === 'granted';
|
||||
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||
const wantsPush = reminderTimes > 0;
|
||||
|
||||
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||
@@ -61,6 +65,13 @@ export default function OnboardingScreen() {
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 同步到 App Group:供 iOS Widget 拉取与展示
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
// 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞)
|
||||
await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile });
|
||||
|
||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||
try {
|
||||
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
@@ -96,10 +107,49 @@ export default function OnboardingScreen() {
|
||||
});
|
||||
await setDailyReminderSettings({
|
||||
timesPerDay: reminderTimes,
|
||||
pushEnabled: pushEnabled
|
||||
// 这里表示“用户意愿”,不代表系统权限一定已 granted
|
||||
pushEnabled: wantsPush,
|
||||
});
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
|
||||
// 用户选择 0 次(关闭)或跳过:直接进入首页
|
||||
if (!wantsPush) {
|
||||
await setPushPromptState('skipped');
|
||||
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) 上报推送偏好(幂等)
|
||||
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
||||
|
||||
await setPushPromptState('enabled');
|
||||
} catch (e) {
|
||||
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await setPushPromptState('unknown');
|
||||
} finally {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
@@ -121,6 +171,10 @@ export default function OnboardingScreen() {
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 同步到 App Group:供 iOS Widget 使用(失败不阻塞)
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
@@ -173,6 +227,11 @@ export default function OnboardingScreen() {
|
||||
value={reminderTimes}
|
||||
onChange={setReminderTimes}
|
||||
onFinish={onFinish}
|
||||
onSkip={() => {
|
||||
// 跳过每日提醒:视为 0 次(关闭)
|
||||
setReminderTimes(0);
|
||||
onFinish();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</OnboardingLayout>
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as WebBrowser from 'expo-web-browser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
|
||||
// 导入 SVG 组件
|
||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||
@@ -16,6 +17,7 @@ export default function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
checkConsent();
|
||||
@@ -44,6 +46,25 @@ export default function SplashScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetchLegalLinks();
|
||||
if (cancelled) return;
|
||||
setLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
||||
} catch (e) {
|
||||
// 不阻塞主流程:失败时不崩溃,链接入口可不展示
|
||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
|
||||
if (!cancelled) setLinks({});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const bgDecorationTop = 363;
|
||||
const bgDecorationHeight = height * 0.6;
|
||||
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||
@@ -81,11 +102,17 @@ export default function SplashScreen() {
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.linksContainer}>
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
|
||||
<TouchableOpacity
|
||||
disabled={!links.privacy}
|
||||
onPress={() => (links.privacy ? openLink(links.privacy) : undefined)}
|
||||
>
|
||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
|
||||
<TouchableOpacity
|
||||
disabled={!links.terms}
|
||||
onPress={() => (links.terms ? openLink(links.terms) : undefined)}
|
||||
>
|
||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -6,9 +6,12 @@ import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import { AppState } from 'react-native';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { initI18n } from '@/src/i18n';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||
|
||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -53,6 +56,17 @@ export default function RootLayout() {
|
||||
.finally(() => setI18nReady(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 尽早生成 client_user_id,便于后续任意时刻与后端建立关联(Push Token/偏好等)
|
||||
getOrCreateClientUserId()
|
||||
.then((id) => {
|
||||
if (__DEV__) console.log('[client_user_id]', id);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('[client_user_id] 生成失败(不阻塞启动)', e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
|
||||
if (loaded && i18nReady) {
|
||||
@@ -70,6 +84,21 @@ export default function RootLayout() {
|
||||
function RootLayoutNav() {
|
||||
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 (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
|
||||
@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
|
||||
}, [visible]);
|
||||
|
||||
function clamp(next: number) {
|
||||
return Math.min(10, Math.max(1, next));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
return Math.min(5, Math.max(0, next));
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (loading) return;
|
||||
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);
|
||||
setLoading(false);
|
||||
onClose();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Di
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Switch } from 'react-native';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import Animated, {
|
||||
Easing,
|
||||
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 * as Notifications from 'expo-notifications';
|
||||
import { changeLanguage } from '@/src/i18n';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
|
||||
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 [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
||||
const [currentName, setCurrentName] = useState(propName);
|
||||
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
const isRoot = page === 'root';
|
||||
|
||||
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||||
@@ -90,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
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 {
|
||||
setNavDirection('back');
|
||||
setPage('root');
|
||||
@@ -123,6 +137,18 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const openLink = useCallback(
|
||||
async (url?: string) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url);
|
||||
} catch (error) {
|
||||
Alert.alert(t('common.error'), t('common.openLinkError'));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (page === 'favorites') return t('profile.favorites');
|
||||
if (page === 'dailyReminder') return t('dailyReminder.title');
|
||||
@@ -182,6 +208,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
onOpenWidget={() => go('widget', 'forward')}
|
||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||
onOpenLanguage={() => go('language', 'forward')}
|
||||
onOpenPrivacy={() => openLink(legalLinks.privacy)}
|
||||
onOpenTerms={() => openLink(legalLinks.terms)}
|
||||
/>
|
||||
) : page === 'favorites' ? (
|
||||
<FavoritesPage visible={visible} page={page} />
|
||||
@@ -211,12 +239,16 @@ function RootPage({
|
||||
onOpenWidget,
|
||||
onOpenDailyReminder,
|
||||
onOpenLanguage,
|
||||
onOpenPrivacy,
|
||||
onOpenTerms,
|
||||
}: {
|
||||
name?: string;
|
||||
onOpenFavorites: () => void;
|
||||
onOpenWidget: () => void;
|
||||
onOpenDailyReminder: () => void;
|
||||
onOpenLanguage: () => void;
|
||||
onOpenPrivacy: () => void;
|
||||
onOpenTerms: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -244,12 +276,12 @@ function RootPage({
|
||||
<ListItem
|
||||
icon={<PrivacyIcon width={22} height={22} />}
|
||||
title={t('profile.privacy')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenPrivacy}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<TermsIcon width={22} height={22} />}
|
||||
title={t('profile.terms')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenTerms}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<LanguageIcon width={22} height={22} />}
|
||||
@@ -373,13 +405,15 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
(async () => {
|
||||
// 1. 获取本地存储设置
|
||||
const s = await getDailyReminderSettings();
|
||||
|
||||
// 【测试模式】:强制模拟无权限状态
|
||||
const granted = false;
|
||||
|
||||
|
||||
// 2. 获取系统通知权限(用于 UI 展示/引导)
|
||||
const settings = await Notifications.getPermissionsAsync();
|
||||
const granted = settings.status === 'granted';
|
||||
|
||||
if (cancelled) return;
|
||||
setTimesPerDay(s.timesPerDay);
|
||||
setPushEnabled(granted);
|
||||
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
|
||||
setPushEnabled(Boolean(s.pushEnabled) && granted);
|
||||
setHasSystemPermission(granted);
|
||||
setLoading(false);
|
||||
})();
|
||||
@@ -412,6 +446,16 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (status === 'granted') {
|
||||
setPushEnabled(true);
|
||||
setHasSystemPermission(true);
|
||||
|
||||
// 获取 token 并上报后端(幂等)
|
||||
try {
|
||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||
await registerPushToken({ pushToken: expoPushToken });
|
||||
await setPushPreferences({ enabled: true, timesPerDay });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
Alert.alert(t('common.notice'), msg);
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
setHasSystemPermission(false);
|
||||
@@ -419,18 +463,35 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
// 关闭时尝试同步到后端(不阻塞)
|
||||
try {
|
||||
await setPushPreferences({ enabled: false, timesPerDay: 0 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function clamp(next: number) {
|
||||
return Math.min(10, Math.max(1, next));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
return Math.min(5, Math.max(0, next));
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (loading) return;
|
||||
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);
|
||||
|
||||
// 同步后端偏好(幂等;失败不阻塞)
|
||||
try {
|
||||
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败', msg);
|
||||
}
|
||||
setLoading(false);
|
||||
onDone();
|
||||
}
|
||||
@@ -464,24 +525,22 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!hasSystemPermission && (
|
||||
<View style={styles.remindRow}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIcon}>
|
||||
<RemindIcon width={18} height={18} />
|
||||
</View>
|
||||
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||
</View>
|
||||
<View style={styles.rowRight}>
|
||||
<Switch
|
||||
value={pushEnabled}
|
||||
onValueChange={handleTogglePush}
|
||||
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
|
||||
ios_backgroundColor="#D1D1D6"
|
||||
/>
|
||||
<View style={styles.remindRow}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIcon}>
|
||||
<RemindIcon width={18} height={18} />
|
||||
</View>
|
||||
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.rowRight}>
|
||||
<Switch
|
||||
value={pushEnabled}
|
||||
onValueChange={handleTogglePush}
|
||||
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
|
||||
ios_backgroundColor="#D1D1D6"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
|
||||
<LinearGradient
|
||||
@@ -537,7 +596,8 @@ function WidgetHowToPage() {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
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' ? [
|
||||
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||
@@ -11,11 +12,15 @@ interface ReminderStepProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onFinish: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleReduce = () => {
|
||||
if (value > 1) onChange(value - 1);
|
||||
// 允许 0~5;0 表示关闭每日提醒
|
||||
if (value > 0) onChange(value - 1);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -31,7 +36,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
|
||||
<View style={styles.numberWrapper}>
|
||||
<Text style={styles.numberText}>{value}</Text>
|
||||
<Text style={styles.unitText}>次</Text>
|
||||
<Text style={styles.unitText}>{t('dailyReminder.timesUnit')}</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||
@@ -43,6 +48,10 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||
<BtnClicked width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -86,4 +95,16 @@ const styles = StyleSheet.create({
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
,
|
||||
skipBtn: {
|
||||
marginTop: 14,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
skipText: {
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
opacity: 0.85,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// 统一的 Widget Extension 入口(模块内只能有一个 @main)
|
||||
@main
|
||||
struct MindfulnessWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
MindfulnessWidget()
|
||||
EmotionWidget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# MindfulnessWidget(WidgetKit 扩展骨架)
|
||||
|
||||
本目录提供 iOS Widget(V1 写死文案)的 SwiftUI 代码骨架。
|
||||
|
||||
注意:**仅把文件放进仓库还不够**,你还需要在 Xcode 中创建 Widget Extension target,并把这些文件加入 target。
|
||||
|
||||
## 目标
|
||||
|
||||
- 支持 Small/Medium/Large 三种尺寸
|
||||
- 展示写死文案
|
||||
- 点击小组件跳转到 App 的 Home:`client:///(app)/home`
|
||||
|
||||
@@ -67,5 +67,84 @@ target 'client' do
|
||||
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
|
||||
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
|
||||
|
||||
@@ -3,6 +3,9 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- EXConstants (18.0.13):
|
||||
- ExpoModulesCore
|
||||
- EXJSONUtils (0.15.0)
|
||||
- EXManifests (1.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXNotifications (0.32.16):
|
||||
- ExpoModulesCore
|
||||
- Expo (54.0.32):
|
||||
@@ -30,8 +33,181 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- 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):
|
||||
- ExpoModulesCore
|
||||
- ExpoCrypto (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (19.0.21):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.11):
|
||||
@@ -74,6 +250,8 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoWebBrowser (15.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface (2.0.0):
|
||||
- ExpoModulesCore
|
||||
- FBLazyVector (0.81.5)
|
||||
- hermes-engine (0.81.5):
|
||||
- hermes-engine/Pre-built (= 0.81.5)
|
||||
@@ -1798,6 +1976,28 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- 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):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
@@ -2040,12 +2240,19 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/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`)"
|
||||
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)"
|
||||
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/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`)"
|
||||
- "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`)"
|
||||
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)"
|
||||
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)"
|
||||
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)"
|
||||
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
|
||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
||||
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
|
||||
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
||||
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)"
|
||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
|
||||
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
|
||||
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)"
|
||||
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
|
||||
@@ -2053,6 +2260,7 @@ DEPENDENCIES:
|
||||
- "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`)"
|
||||
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/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`)"
|
||||
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/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`)"
|
||||
- "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`)"
|
||||
- "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`)"
|
||||
@@ -2122,6 +2330,7 @@ DEPENDENCIES:
|
||||
- "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`)"
|
||||
- "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`)"
|
||||
- "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`)"
|
||||
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler`)"
|
||||
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)"
|
||||
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
|
||||
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
|
||||
@@ -2133,18 +2342,32 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
|
||||
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"
|
||||
EXJSONUtils:
|
||||
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios"
|
||||
EXManifests:
|
||||
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios"
|
||||
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"
|
||||
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"
|
||||
expo-dev-client:
|
||||
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios"
|
||||
expo-dev-launcher:
|
||||
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher"
|
||||
expo-dev-menu:
|
||||
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu"
|
||||
expo-dev-menu-interface:
|
||||
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios"
|
||||
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"
|
||||
ExpoCrypto:
|
||||
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
|
||||
ExpoFileSystem:
|
||||
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
|
||||
ExpoFont:
|
||||
: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"
|
||||
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/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios"
|
||||
ExpoKeepAwake:
|
||||
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
|
||||
ExpoLinearGradient:
|
||||
@@ -2159,6 +2382,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
|
||||
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"
|
||||
EXUpdatesInterface:
|
||||
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios"
|
||||
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"
|
||||
hermes-engine:
|
||||
@@ -2296,6 +2521,8 @@ EXTERNAL SOURCES:
|
||||
: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"
|
||||
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"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler"
|
||||
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"
|
||||
RNScreens:
|
||||
@@ -2310,9 +2537,16 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||
@@ -2323,6 +2557,7 @@ SPEC CHECKSUMS:
|
||||
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
@@ -2391,12 +2626,13 @@ SPEC CHECKSUMS:
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||
|
||||
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
|
||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 70;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -11,7 +11,10 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
@@ -50,7 +53,9 @@
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
||||
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -58,7 +63,8 @@
|
||||
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; };
|
||||
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; };
|
||||
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>"; };
|
||||
@@ -66,7 +72,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
EmotionWidget.swift,
|
||||
@@ -77,7 +83,18 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet 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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -86,6 +103,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -104,8 +122,10 @@
|
||||
13B07FAE1A68108700A75B9A /* client */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */,
|
||||
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */,
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */,
|
||||
F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
@@ -145,6 +165,7 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
|
||||
13B07FAE1A68108700A75B9A /* client */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||
@@ -189,7 +210,7 @@
|
||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
@@ -367,6 +388,8 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
@@ -380,6 +403,8 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -441,6 +466,8 @@
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -448,7 +475,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -473,6 +500,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
@@ -492,7 +520,7 @@
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||
PRODUCT_NAME = HeyMama;
|
||||
SKIP_INSTALL = YES;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -512,11 +540,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||
CODE_SIGN_ENTITLEMENTS = client/clientRelease.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
INFOPLIST_FILE = client/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -532,7 +561,7 @@
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||
PRODUCT_NAME = HeyMama;
|
||||
SKIP_INSTALL = YES;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -734,6 +763,7 @@
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "2.2">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
parallelizeBuildables = "NO"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
@@ -15,6 +15,20 @@
|
||||
<AutocreatedTestPlanReference>
|
||||
</AutocreatedTestPlanReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E99E52E7F84CEF53B06494B581AFB6E4"
|
||||
BuildableName = "libPods-client.a"
|
||||
BlueprintName = "Pods-client"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
|
||||
57
client/ios/client/AppGroupStorage.swift
Normal file
57
client/ios/client/AppGroupStorage.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import React
|
||||
import WidgetKit
|
||||
|
||||
/**
|
||||
* App Group 共享存储(RN Bridge)
|
||||
*
|
||||
* 约定:
|
||||
* - suiteName:group.com.damer.mindfulness(已在主 App 与 Widget Extension 的 entitlements 配置)
|
||||
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
||||
*/
|
||||
@objc(AppGroupStorage)
|
||||
final class AppGroupStorage: NSObject, RCTBridgeModule {
|
||||
static func moduleName() -> String! {
|
||||
"AppGroupStorage"
|
||||
}
|
||||
|
||||
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 UserDefaults(suiteName=\(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 UserDefaults(suiteName=\(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)
|
||||
}
|
||||
}
|
||||
|
||||
26
client/ios/client/AppGroupStorageBridge.m
Normal file
26
client/ios/client/AppGroupStorageBridge.m
Normal 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
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//
|
||||
// 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/RCTBundleURLProvider.h>
|
||||
#import <React/RCTLinkingManager.h>
|
||||
|
||||
@@ -4,5 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<!-- iOS 小组件需要通过 App Group 与主 App 共享数据 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.damer.mindfulness</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
12
client/ios/client/clientRelease.entitlements
Normal file
12
client/ios/client/clientRelease.entitlements
Normal 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>
|
||||
100
client/ios/scripts/fix-xcarchive-header.sh
Executable file
100
client/ios/scripts/fix-xcarchive-header.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
|
||||
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
||||
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
||||
#
|
||||
# 用法:
|
||||
# ./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
|
||||
|
||||
# 取第一个 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)"
|
||||
|
||||
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"
|
||||
|
||||
# 如果已有 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"
|
||||
@@ -1,49 +1,261 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// V1:写死文案的小组件(Small/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 = "You’ve 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:10~01: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 {
|
||||
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) -> ()) {
|
||||
completion(EmotionEntry(date: Date()))
|
||||
completion(placeholder(in: context))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||
// V1:内容写死,不做数据更新;给一个较长的刷新间隔(系统仍可能自行调度)
|
||||
let entry = EmotionEntry(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)))
|
||||
Task {
|
||||
let lang = resolveLang()
|
||||
let today = localDayKey(Date())
|
||||
|
||||
// 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 {
|
||||
let date: Date
|
||||
let lang: String
|
||||
let title: String
|
||||
let text: String
|
||||
let footerHint: String
|
||||
}
|
||||
|
||||
struct EmotionWidgetView: View {
|
||||
var entry: EmotionProvider.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()
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||
])
|
||||
|
||||
// 只显示一句话(不显示标题/提示/时间等装饰元素)
|
||||
Text(entry.text)
|
||||
.font(fontForFamily())
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(lineSpacingForFamily())
|
||||
.lineLimit(lineLimitForFamily())
|
||||
.minimumScaleFactor(0.78)
|
||||
.padding(paddingForFamily())
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
// 统一的“卡片背景”风格(iOS 15 兼容)
|
||||
@@ -69,122 +281,52 @@ struct EmotionWidgetView: View {
|
||||
.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 fontForFamily() -> Font {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return .system(size: 16, weight: .semibold)
|
||||
case .systemMedium:
|
||||
return .system(size: 18, weight: .semibold)
|
||||
case .systemLarge:
|
||||
return .system(size: 22, weight: .semibold)
|
||||
default:
|
||||
return .system(size: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private func lineSpacingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemLarge:
|
||||
return 4
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
.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 {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||
])
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
chip(title)
|
||||
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 func paddingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 14
|
||||
case .systemMedium:
|
||||
return 16
|
||||
case .systemLarge:
|
||||
return 18
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
client/ios/情绪小组件ExtensionRelease.entitlements
Normal file
10
client/ios/情绪小组件ExtensionRelease.entitlements
Normal 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>
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"ios": "expo run:ios --scheme \"Hey Mama\"",
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run"
|
||||
},
|
||||
@@ -15,7 +15,9 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.32",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "^6.0.20",
|
||||
"expo-device": "^8.0.10",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-linear-gradient": "^15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
|
||||
32
client/pnpm-lock.yaml
generated
32
client/pnpm-lock.yaml
generated
@@ -23,9 +23,15 @@ importers:
|
||||
expo-constants:
|
||||
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))
|
||||
expo-crypto:
|
||||
specifier: ^15.0.8
|
||||
version: 15.0.8(expo@54.0.32)
|
||||
expo-dev-client:
|
||||
specifier: ^6.0.20
|
||||
version: 6.0.20(expo@54.0.32)
|
||||
expo-device:
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(expo@54.0.32)
|
||||
expo-font:
|
||||
specifier: ~14.0.11
|
||||
version: 14.0.11(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -2229,6 +2235,11 @@ packages:
|
||||
expo: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-crypto@15.0.8:
|
||||
resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-dev-client@6.0.20:
|
||||
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
|
||||
peerDependencies:
|
||||
@@ -2249,6 +2260,11 @@ packages:
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-device@8.0.10:
|
||||
resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-file-system@19.0.21:
|
||||
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
|
||||
peerDependencies:
|
||||
@@ -3811,6 +3827,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@0.7.41:
|
||||
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@1.0.41:
|
||||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||
hasBin: true
|
||||
@@ -6537,6 +6557,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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):
|
||||
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)
|
||||
@@ -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-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)):
|
||||
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)
|
||||
@@ -8282,6 +8312,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ua-parser-js@0.7.41: {}
|
||||
|
||||
ua-parser-js@1.0.41: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
@@ -45,8 +45,10 @@ export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
||||
* 调试:打印环境变量注入结果(仅开发环境)
|
||||
*
|
||||
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
|
||||
*
|
||||
* 注意:在某些测试环境(如 vitest)里 `__DEV__` 可能不存在,需做兼容判断。
|
||||
*/
|
||||
if (__DEV__) {
|
||||
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||
const injected = {
|
||||
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
|
||||
EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"later": "稍后",
|
||||
"loading": "处理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
|
||||
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
|
||||
173
client/src/modules/dailyWidgetReco/index.ts
Normal file
173
client/src/modules/dailyWidgetReco/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
|
||||
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 共享 key(App ↔ 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 能拿到 baseURL(dev/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 = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
client/src/services/__tests__/legalApi.test.ts
Normal file
39
client/src/services/__tests__/legalApi.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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('任意 zh* 归一为 tc', () => {
|
||||
setLang('zh-CN');
|
||||
expect(buildAcceptLanguage()).toBe('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');
|
||||
});
|
||||
});
|
||||
|
||||
53
client/src/services/appGroupStorage.ts
Normal file
53
client/src/services/appGroupStorage.ts
Normal 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();
|
||||
}
|
||||
|
||||
37
client/src/services/legalApi.ts
Normal file
37
client/src/services/legalApi.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type LegalLinks = {
|
||||
privacyPolicyUrl: string;
|
||||
termsOfUseUrl: string;
|
||||
resolvedLang: 'en' | 'tc';
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
const lang = (i18n.language || '').trim();
|
||||
const lower = lang.toLowerCase();
|
||||
|
||||
// 当前多语言仅支持 EN / TC(与 reco 链路一致);其他语言统一回退到 en
|
||||
if (lower.startsWith('zh')) {
|
||||
return 'tc';
|
||||
}
|
||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) {
|
||||
return 'tc';
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
210
client/src/services/pushApi.ts
Normal file
210
client/src/services/pushApi.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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';
|
||||
|
||||
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; // 0~5
|
||||
timezone?: string;
|
||||
locale?: string;
|
||||
// 可选:用户画像(用于后端 Push 场景推荐文案生成)
|
||||
user_profile?: UserProfileScoring;
|
||||
};
|
||||
|
||||
export type PushPreferencesResponse = PushPreferencesRequest & {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
const lang = (i18n.language || '').trim();
|
||||
const lower = lang.toLowerCase();
|
||||
if (lower.startsWith('zh')) return 'tc';
|
||||
if (lower.includes('tc') || lower.includes('hant') || lower.includes('hk') || lower.includes('mo') || lower.includes('tw')) return 'tc';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
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 官方推荐读取 projectId(EAS/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 ||
|
||||
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,建议在 app.json 的 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 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
|
||||
import type { UserProfileV1_2 } from '../features/userProfileScoring';
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type RecommendedItem = {
|
||||
content_id: number;
|
||||
@@ -26,19 +26,10 @@ export type RecoRequest = {
|
||||
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> {
|
||||
const controller = withTimeout(12_000);
|
||||
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': acceptLanguage,
|
||||
};
|
||||
@@ -50,25 +41,39 @@ export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult>
|
||||
now: req.now,
|
||||
};
|
||||
|
||||
// 仅在开发环境打印,避免生产环境日志泄露敏感信息
|
||||
if (__DEV__) {
|
||||
console.log('[Feed API] 请求地址:', url);
|
||||
console.log('[Feed API] Feed的API请求头:', headers);
|
||||
console.log('[Feed API] Feed的API请求体:', bodyObj);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
return await httpJson<RecoEngineResult>({
|
||||
path: '/v1/reco/feed',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(bodyObj),
|
||||
signal: controller.signal,
|
||||
body: bodyObj,
|
||||
timeoutMs: 12_000,
|
||||
debugLabel: 'Feed API',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||
|
||||
/**
|
||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||
*/
|
||||
const KEY_CLIENT_USER_ID = 'client.userId';
|
||||
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
|
||||
const KEY_PUSH_PROMPT_STATE = 'push.promptState';
|
||||
const KEY_CONTENT_REACTIONS = 'content.reactions';
|
||||
@@ -31,7 +33,16 @@ export type UserProfile = {
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
/**
|
||||
* 每天推送次数:
|
||||
* - 0:关闭
|
||||
* - 1~5:每天推送 1~5 次
|
||||
*/
|
||||
timesPerDay: number;
|
||||
/**
|
||||
* 仅用于 UI 展示与交互(开关)。
|
||||
* 后端偏好建议使用:enabled = pushEnabled && timesPerDay > 0
|
||||
*/
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -81,6 +92,49 @@ async function setJson<T>(key: string, value: T): Promise<void> {
|
||||
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> {
|
||||
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
|
||||
return raw === 'true';
|
||||
@@ -272,13 +326,15 @@ export async function getDailyReminderSettings(): Promise<DailyReminderSettings>
|
||||
});
|
||||
const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3;
|
||||
return {
|
||||
timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))),
|
||||
// 需求:0~5(0 表示关闭)
|
||||
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
|
||||
pushEnabled: Boolean(s.pushEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> {
|
||||
const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay)));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
|
||||
await setJson(KEY_DAILY_REMINDER_SETTINGS, {
|
||||
timesPerDay,
|
||||
pushEnabled: Boolean(settings.pushEnabled),
|
||||
|
||||
14
client/src/utils/date.ts
Normal file
14
client/src/utils/date.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
146
client/src/utils/http.ts
Normal file
146
client/src/utils/http.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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.stringify;GET 请求请不要传 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);
|
||||
throw new Error(`网络请求失败:${msg}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user