main #14

Open
damer wants to merge 25 commits from main into damer
73 changed files with 2707 additions and 536 deletions

View File

@@ -1,6 +1,6 @@
{
"expo": {
"name": "Hey Mama",
"name": "Dear Mama",
"slug": "client",
"version": "1.0.0",
"orientation": "portrait",
@@ -15,6 +15,7 @@
},
"ios": {
"supportsTablet": true,
"requireFullScreen": true,
"bundleIdentifier": "com.damer.mindfulness"
},
"android": {

View File

@@ -2,13 +2,14 @@ import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } fr
import {
StyleSheet,
View,
Dimensions,
Text,
Pressable,
PanResponder,
AppState,
Animated as RNAnimated,
ImageBackground,
Platform,
useWindowDimensions,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { useFocusEffect } from 'expo-router';
@@ -29,6 +30,8 @@ import {
getUserProfile,
setReaction,
setThemeMode,
clearPendingHomePushMessage,
getPendingHomePushMessage,
getRecoFeedCache,
setRecoFeedCache,
getUserProfileScoring,
@@ -41,6 +44,7 @@ import {
} from '@/src/storage/appStorage';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { subscribeHomePushMessage } from '@/src/services/pushNotificationRoute';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import ProfileModal from '@/components/home/ProfileModal';
@@ -55,8 +59,7 @@ import { getBootId } from '@/src/utils/bootSession';
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
import { wrapText } from '@/src/features/textWrap';
import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
// 预定义风景图列表
const NATURE_IMAGES = [
@@ -96,7 +99,9 @@ type FeedItem = { content_id: string; text: string };
export default function HomeScreen() {
const { t, i18n } = useTranslation();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const isEnglish = i18n.language?.startsWith('en');
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
const insets = useSafeAreaInsets();
const [index, setIndex] = useState(0);
@@ -108,10 +113,41 @@ export default function HomeScreen() {
const [busy, setBusy] = useState(false);
const [likeFilled, setLikeFilled] = useState(false);
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
const [pendingPushItem, setPendingPushItem] = useState<FeedItem | null>(null);
const [isFetching, setIsFetching] = useState(false);
const [cardWidth, setCardWidth] = useState<number | null>(null);
const [wrappedText, setWrappedText] = useState<string>('');
const wrapLogRef = useRef<{ key: string } | null>(null);
const busyRef = useRef(false);
const indexRef = useRef(0);
const currentFeedRef = useRef<FeedItem[]>([]);
const likedIdsRef = useRef<Set<string>>(new Set());
const likeInFlightRef = useRef(false);
const applyPendingPushItem = useCallback(async (message: { notification_id: string; content_id?: number; text: string }) => {
const nextItem: FeedItem = {
content_id: message.content_id != null ? String(message.content_id) : `push:${message.notification_id}`,
text: message.text,
};
setPendingPushItem(nextItem);
indexRef.current = 0;
setIndex(0);
setLikeFilled(likedIdsRef.current.has(String(nextItem.content_id)));
await clearPendingHomePushMessage();
}, []);
const consumePendingPushItem = useCallback(async () => {
const pendingMessage = await getPendingHomePushMessage();
if (!pendingMessage?.text) return;
await applyPendingPushItem(pendingMessage);
}, [applyPendingPushItem]);
useEffect(() => {
busyRef.current = busy;
}, [busy]);
useEffect(() => {
indexRef.current = index;
}, [index]);
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
@@ -174,14 +210,28 @@ export default function HomeScreen() {
// 统一文案对象结构
const currentFeed = useMemo(() => {
if (feedItems.length > 0) {
return feedItems;
const baseFeed =
feedItems.length > 0
? feedItems
: MOCK_CONTENT.map(item => ({
content_id: item.id,
text: t(item.textKey)
}));
if (!pendingPushItem) {
return baseFeed;
}
return MOCK_CONTENT.map(item => ({
content_id: item.id,
text: t(item.textKey)
}));
}, [feedItems, t]);
return [
pendingPushItem,
...baseFeed.filter((entry) => (
String(entry.content_id) !== String(pendingPushItem.content_id) && entry.text !== pendingPushItem.text
)),
];
}, [feedItems, pendingPushItem, t]);
useEffect(() => {
currentFeedRef.current = currentFeed;
}, [currentFeed]);
const item = useMemo(() => {
const data = currentFeed[index % currentFeed.length];
@@ -232,8 +282,8 @@ export default function HomeScreen() {
});
const fontSpec = {
fontSize: 22,
fontWeight: lang === 'EN' ? '600' : '700',
fontSize: 24,
fontWeight: lang === 'EN' ? '700' : '800',
fontFamily: String(fontFamily ?? 'System'),
};
@@ -265,11 +315,24 @@ export default function HomeScreen() {
maxLines: 3,
overflowMode: 'CLIP',
lineMode: 'AUTO',
configVersion: 'v1',
// Home采用“更偏好语气停顿/更好看”的排版风格微调(不影响算法默认 v1
configVersion: 'v1-home',
debug: __DEV__,
fontSpec,
contextProfile: `APP|${Platform.OS}|home|${lang}`,
measureWidthImpl: defaultMeasureWidthImpl,
scoringOverrides:
lang === 'TC'
? {
// 更偏好在逗号/句号等处断行(即便宽度允许也不一定要塞满)
weights: { R_PUNCT_BREAK: 180 },
// 让“理想行宽”更短,避免宽屏下过度延后断行
idealWidthRatio: { APP: 0.82 },
// 更宽容短行(尤其是第一行在标点处停顿)
minPreferredRatio: 0.45,
shortLastLineRatio: 0.45,
}
: undefined,
});
if (cancelled) return;
@@ -352,13 +415,20 @@ export default function HomeScreen() {
useCallback(() => {
let cancelled = false;
(async () => {
const mode = await getThemeMode();
const profile = await getUserProfile();
const cache = await getRecoFeedCache();
const [mode, profile, cache, pendingMessage] = await Promise.all([
getThemeMode(),
getUserProfile(),
getRecoFeedCache(),
getPendingHomePushMessage(),
]);
if (cancelled) return;
setThemeModeState(mode);
setProfileName(profile.name);
if (pendingMessage?.text) {
await applyPendingPushItem(pendingMessage);
if (cancelled) return;
}
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
if (mode === 'suixin') {
@@ -377,13 +447,33 @@ export default function HomeScreen() {
setIndex(0);
fetchNewFeed();
}
// Widget前台辅助刷新尽力而为
// - 写入 App Group 的 dailyReco 缓存
// - 生成 wrapped_text_by_family供 Widget 直接渲染
ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {});
})();
return () => {
cancelled = true;
};
}, [fetchNewFeed, recoLang, ensureSuixinReady])
}, [applyPendingPushItem, fetchNewFeed, recoLang, ensureSuixinReady])
);
useEffect(() => {
const unsubscribe = subscribeHomePushMessage((message) => {
void applyPendingPushItem(message);
});
return unsubscribe;
}, [applyPendingPushItem]);
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state !== 'active') return;
void consumePendingPushItem();
});
return () => sub.remove();
}, [consumePendingPushItem]);
const backgroundColor = useMemo(() => {
if (themeMode === 'suixin') {
return suixinBgColor;
@@ -411,24 +501,60 @@ export default function HomeScreen() {
transform: [{ scale: likeScale.value }],
}));
const setBusySafe = useCallback((next: boolean) => {
busyRef.current = next;
setBusy(next);
}, []);
const setLikeInFlight = useCallback((next: boolean) => {
likeInFlightRef.current = next;
}, []);
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
const list = currentFeedRef.current;
const len = list.length;
if (!len) {
setLikeFilled(false);
return;
}
const safe = ((nextIndex % len) + len) % len;
const nextId = String(list[safe]?.content_id);
setLikeFilled(likedIdsRef.current.has(nextId));
}, []);
const applyIndexChange = useCallback((nextIndex: number) => {
indexRef.current = nextIndex;
setIndex(nextIndex);
syncLikeFilledByIndex(nextIndex);
}, [syncLikeFilledByIndex]);
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
const len = currentFeedRef.current.length;
if (!len) return;
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
if (nextIndex + 5 >= len && !isFetchingRef.current) {
fetchNewFeed();
}
}, [fetchNewFeed]);
// 切换到下一条文案的统一动画逻辑
const triggerNextContent = useCallback(() => {
if (busy) return;
setBusy(true);
if (busyRef.current) return;
setBusySafe(true);
// 注意:不要在 Reanimated worklet 回调里读取 React ref例如 indexRef/currentFeedRef会导致值不更新或异常
const nextIndex = indexRef.current + 1;
// 1. 当前文案向上移动并消失
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
// 2. 切换数据索引
runOnJS(setIndex)(index + 1);
runOnJS(setLikeFilled)(false);
runOnJS(applyIndexChange)(nextIndex);
runOnJS(advanceSuixinOnNextContent)();
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条
if (index + 5 >= currentFeed.length && !isFetching) {
runOnJS(fetchNewFeed)();
}
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS可能导致原生崩溃
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
// 3. 准备下一条文案:先瞬移到下方 40pt
translateY.value = 40;
@@ -437,20 +563,51 @@ export default function HomeScreen() {
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
if (finished) {
runOnJS(setBusy)(false);
runOnJS(setBusySafe)(false);
}
});
}
});
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
// 切换到上一条文案的统一动画逻辑(下滑触发)
const triggerPrevContent = useCallback(() => {
if (busyRef.current) return;
setBusySafe(true);
// 注意:同上,不要在 worklet 里读取 React ref
const len = currentFeedRef.current.length;
const raw = indexRef.current - 1;
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
// 1. 当前文案向下移动并消失
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
// 2. 切换数据索引(循环回退)
runOnJS(applyIndexChange)(nextIndex);
// 3. 准备上一条文案:先瞬移到上方 40pt
translateY.value = -40;
// 4. 上一条文案向下移动到原位并显现
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
if (finished) {
runOnJS(setBusySafe)(false);
}
});
}
});
}, [applyIndexChange, setBusySafe, translateY, opacity]);
const lastTapRef = useRef<number>(0);
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
const handlersRef = useRef({ onPressLike, triggerNextContent });
const handlersRef = useRef({ onPressLike, triggerNextContent, triggerPrevContent });
useEffect(() => {
handlersRef.current = { onPressLike, triggerNextContent };
}, [onPressLike, triggerNextContent]);
handlersRef.current = { onPressLike, triggerNextContent, triggerPrevContent };
}, [onPressLike, triggerNextContent, triggerPrevContent]);
// 使用系统自带的 PanResponder 代替第三方手势库
const panResponder = useRef(
@@ -475,16 +632,32 @@ export default function HomeScreen() {
}
lastTapRef.current = now;
// 2. 上滑逻辑判定
// 2. 上滑/下滑逻辑判定
if (gestureState.dy < -50) { // 上滑超过 50pt
runOnJS(handlersRef.current.triggerNextContent)();
} else if (gestureState.dy > 50) { // 下滑超过 50pt
runOnJS(handlersRef.current.triggerPrevContent)();
}
},
})
).current;
async function onPressLike() {
if (busy) return;
if (busyRef.current) return;
if (likeInFlightRef.current) return;
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
if (likeFilled || likedIdsRef.current.has(item.id)) {
triggerNextContent();
return;
}
likeInFlightRef.current = true;
const likedItemId = item.id;
const likedItemText = item.text;
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
likedIdsRef.current.add(likedItemId);
setLikeFilled(true);
// 1. 获取当前日期
@@ -494,24 +667,33 @@ export default function HomeScreen() {
// 2. 保存到收藏夹,包含当前背景信息
const favItem = {
favId: String(Date.now()), // 生成唯一 ID
id: item.id,
text: item.text,
id: likedItemId,
text: likedItemText,
date: dateStr,
themeMode: themeMode,
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
};
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
await addFavorite(favItem);
try {
await addFavorite(favItem);
} catch (error) {
console.error('Home: addFavorite 失败', error);
}
// 3. 记录到后端 Reaction喜欢
console.log('Home: Triggering setReaction', item.id);
await setReaction(item.id, 'like');
try {
await setReaction(item.id, 'like');
} catch (error) {
console.error('Home: setReaction 失败', error);
}
// 4. 爱心缩放动画
likeScale.value = withSequence(
withTiming(0.8, { duration: 100 }),
withTiming(1.2, { duration: 150 }),
withTiming(1, { duration: 100 }, (finished) => {
runOnJS(setLikeInFlight)(false);
if (finished) {
console.log('Home: Like animation finished, triggering next content');
runOnJS(triggerNextContent)();
@@ -533,6 +715,10 @@ export default function HomeScreen() {
}
}
const actionsBottom = isTablet
? Math.max(insets.bottom + 36, Math.min(windowHeight * 0.12, 140))
: windowHeight * 0.16;
return (
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
{themeMode === 'scenery' && (
@@ -544,48 +730,59 @@ export default function HomeScreen() {
)}
{/* 自绘顶部按钮:不使用系统 Header彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
<View style={[styles.topRight, { top: insets.top + 8 }]}>
<View
style={[
styles.topRight,
{
top: insets.top + (isTablet ? 16 : 8),
right: isTablet ? 26 : 20,
},
]}
>
<CircleIconButton
onPress={() => setThemeOpen(true)}
accessibilityLabel={t('home.theme')}
>
<ThemeIcon width={18} height={18} />
<ThemeIcon width={20} height={20} />
</CircleIconButton>
<CircleIconButton
onPress={() => setProfileOpen(true)}
accessibilityLabel={t('home.profile')}
>
<MyIcon width={18} height={18} />
<MyIcon width={20} height={20} />
</CircleIconButton>
</View>
<Animated.View
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}
onLayout={(e) => {
const w = e.nativeEvent.layout.width;
if (Number.isFinite(w) && w > 0) setCardWidth(w);
}}
>
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
{wrappedText || item.text}
</Text>
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
<View
style={[styles.textMeasureBox, isTablet && styles.textMeasureBoxTablet]}
onLayout={(e) => {
const w = e.nativeEvent.layout.width;
if (Number.isFinite(w) && w > 0) setCardWidth(w);
}}
>
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
{wrappedText || item.text}
</Text>
</View>
</Animated.View>
<View style={styles.actions}>
<View style={[styles.actions, { bottom: actionsBottom }]}>
<Animated.View style={[styles.reactionButton, likeAnimatedStyle]}>
<Pressable
onPress={onPressLike}
accessibilityRole="button"
accessibilityLabel={t('home.like')}
hitSlop={20}
// 稍微增大可点击区域,提升单手操作成功率
hitSlop={24}
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={35} height={36} color="#EA6969" />
<LikeFilledIcon width={40} height={41} color="#EA6969" />
) : (
<LikeIcon
width={35}
height={36}
width={40}
height={41}
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
/>
)}
@@ -615,7 +812,8 @@ function CircleIconButton({
return (
<Pressable
onPress={onPress}
hitSlop={10}
// 稍微增大可点击区域,提升易用性
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.circleBtn}
@@ -634,15 +832,14 @@ const styles = StyleSheet.create({
},
topRight: {
position: 'absolute',
right: 20,
flexDirection: 'row',
gap: 10,
zIndex: 30,
},
circleBtn: {
width: 34,
height: 34,
borderRadius: 17,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.75)',
alignItems: 'center',
justifyContent: 'center',
@@ -659,16 +856,23 @@ const styles = StyleSheet.create({
zIndex: 5, // 降低层级,防止遮挡底部按钮
},
text: {
fontSize: 22,
lineHeight: 32,
fontSize: 24,
lineHeight: 34,
color: '#5E2A28',
fontWeight: '700',
fontWeight: '800',
textAlign: 'center',
},
textMeasureBox: {
width: '100%',
alignItems: 'center',
},
textMeasureBoxTablet: {
maxWidth: 760,
},
textEnglish: {
fontFamily: 'STIXTwoText',
// 英文字体观感更细一点,避免过粗
fontWeight: '600',
// 英文字体保持较粗但避免过度发黑
fontWeight: '700',
},
sceneryCard: {
// 风景模式下稍微收窄文案宽度,增加呼吸感
@@ -682,7 +886,6 @@ const styles = StyleSheet.create({
},
actions: {
position: 'absolute',
bottom: SCREEN_HEIGHT * 0.16,
left: 0,
right: 0,
flexDirection: 'row',

View File

@@ -1,9 +1,13 @@
import Constants from 'expo-constants';
import { StyleSheet, Text, View } from 'react-native';
import { Platform, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function SettingsScreen() {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 720, 24) : undefined;
const version =
Constants.expoConfig?.version ??
@@ -12,6 +16,7 @@ export default function SettingsScreen() {
return (
<View style={styles.container}>
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<View style={styles.section}>
<Text style={styles.label}>{t('settings.version')}</Text>
<Text style={styles.value}>{version}</Text>
@@ -21,12 +26,18 @@ export default function SettingsScreen() {
<Text style={styles.cardTitle}>{t('settings.widgetTitle')}</Text>
<Text style={styles.cardText}>{t('settings.widgetDesc')}</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 16 },
container: { flex: 1, width: '100%', alignSelf: 'stretch', padding: 16 },
contentWrap: {
width: '100%',
alignSelf: 'center',
gap: 16,
},
section: {
borderRadius: 14,
padding: 16,
@@ -38,7 +49,12 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
label: { color: '#374151', fontSize: 16 },
value: { color: '#111827', fontSize: 16, fontWeight: '600' },
value: {
color: '#111827',
fontSize: 16,
fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : undefined,
},
card: {
borderRadius: 16,
padding: 16,

View File

@@ -12,7 +12,7 @@ import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnai
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
import { fetchRecoFeed } from '@/src/services/recoApi';
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
import {
recordRecoFeedServed,
setOnboardingCompleted,
@@ -44,6 +44,7 @@ export default function OnboardingScreen() {
const [name, setName] = useState('');
const [selections, setSelections] = useState<Record<string, string[]>>({});
const [reminderTimes, setReminderTimes] = useState(3);
const [finishing, setFinishing] = useState(false);
const currentStep = STEPS[stepIndex];
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
@@ -56,6 +57,9 @@ export default function OnboardingScreen() {
}, [t, currentStep]);
async function onFinish() {
if (finishing) return;
setFinishing(true);
try {
// 用户选择每日次数 > 0在此页直接触发系统通知权限已移除单独的 push 引导页)。
const wantsPush = reminderTimes > 0;
@@ -124,7 +128,8 @@ export default function OnboardingScreen() {
await setPushPromptState('unknown');
try {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
// iOS 可能出现 provisional临时授权也应视为“已授权”
if (status !== 'granted' && status !== ('provisional' as any)) {
await setPushPromptState('skipped');
return;
}
@@ -136,10 +141,8 @@ export default function OnboardingScreen() {
return;
}
// 1) 获取 Expo Push Token失败才认为“推送开启失败”)
const expoPushToken = await getExpoPushTokenOrThrow();
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await registerPushToken({ pushToken: expoPushToken });
// 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
await ensurePushTokenRegisteredIfPermitted();
// 3) 上报推送偏好(幂等)
// 注意:这一步失败时,后端仍可能已成功接收 token。
@@ -159,9 +162,17 @@ export default function OnboardingScreen() {
} finally {
router.replace('/(app)/home');
}
} catch (e) {
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading提示并允许用户重试
const msg = e instanceof Error ? e.message : String(e);
console.warn('[OnboardingFinish] 异常:', msg);
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
setFinishing(false);
}
}
const onNext = () => {
if (finishing) return;
if (stepIndex < STEPS.length - 1) {
setStepIndex(stepIndex + 1);
} else {
@@ -170,23 +181,24 @@ export default function OnboardingScreen() {
};
const onBack = () => {
if (finishing) return;
if (stepIndex > 0) {
setStepIndex(stepIndex - 1);
}
};
const onSkip = async () => {
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用
const scoringProfile = buildUserProfileFromQuestionnaire({});
await setUserProfileScoring(scoringProfile);
// 同步到 App Group供 iOS Widget 使用(失败不阻塞)
await syncWidgetConfig();
await syncWidgetUserProfileFromScoring(scoringProfile);
// 标记已完成,避免下次启动再次进入 Onboarding
await setOnboardingCompleted(true);
router.replace('/(app)/home');
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
const handleSkipCurrentStep = () => {
if (finishing) return;
if (currentStep.type === 'name') {
onNext();
} else if (currentStep.type === 'selection') {
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
} else if (currentStep.type === 'reminder') {
setReminderTimes(0);
onFinish();
}
};
// 题目为多选:点击切换选中状态
@@ -201,6 +213,7 @@ export default function OnboardingScreen() {
};
const handleSkipStep = () => {
if (finishing) return;
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
onNext();
};
@@ -210,9 +223,10 @@ export default function OnboardingScreen() {
title={currentTitle}
currentStep={stepIndex}
totalSteps={STEPS.length - 1}
onSkip={onSkip}
onSkip={handleSkipCurrentStep}
onBack={onBack}
showBackButton={stepIndex > 0}
userName={name}
>
{currentStep.type === 'name' && (
<NameInputStep
@@ -233,9 +247,10 @@ export default function OnboardingScreen() {
{currentStep.type === 'reminder' && (
<ReminderStep
value={reminderTimes}
value={Math.max(1, reminderTimes)}
onChange={setReminderTimes}
onFinish={onFinish}
loading={finishing}
onSkip={() => {
// 跳过每日提醒:视为 0 次(关闭)
setReminderTimes(0);

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Alert, Image, useWindowDimensions } from 'react-native';
import { useRouter } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import { Trans, useTranslation } from 'react-i18next';
@@ -8,17 +8,38 @@ import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appSto
import { fetchLegalLinks } from '@/src/services/legalApi';
import { getOnboardingCompleted } from '@/src/storage/appStorage';
import { API_BASE_URL } from '@/src/constants/env';
import { isTraditionalChineseLocaleTag } from '@/src/i18n/locale';
// 导入 SVG 组件
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
import WelcomeBtn from '../../assets/images/index/welcome_btn.svg';
const { width, height } = Dimensions.get('window');
// 繁中開屏 consent 文案:寫死在元件內,避免 Metro/iOS bundle 快取導致永遠顯示舊文案。
// 若需修改,請改這裡並同步 client/src/i18n/locales/zh-TW.json 的 consent 區塊。
const ZH_TW_CONSENT = {
title: '我們知道,',
subtitle: '當媽媽很不容易。',
subtitleSecondary: '這裡給你一些溫柔的肯定與提醒',
};
export default function SplashScreen() {
const router = useRouter();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { width, height } = useWindowDimensions();
const [showConsent, setShowConsent] = useState(false);
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
// 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW其餘用 i18n
const isZhTW = isTraditionalChineseLocaleTag(i18n.language || '');
const title = isZhTW ? ZH_TW_CONSENT.title : t('consent.title');
const subtitle = isZhTW ? ZH_TW_CONSENT.subtitle : t('consent.subtitle');
const subtitleSecondary = isZhTW ? ZH_TW_CONSENT.subtitleSecondary : t('consent.subtitleSecondary');
useEffect(() => {
if (typeof __DEV__ !== 'undefined' && __DEV__ && showConsent) {
console.log('[i18n consent] language=', i18n.language, 'title=', title, 'subtitle=', subtitle);
}
}, [showConsent, i18n.language, title, subtitle]);
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
const [linksLoading, setLinksLoading] = useState(false);
const mountedRef = useRef(true);
@@ -106,49 +127,67 @@ export default function SplashScreen() {
void refreshLegalLinks();
}, []);
const bgDecorationTop = 363;
const bgDecorationHeight = height * 0.6;
const bgDecorationHeight = isTablet ? Math.round(height * 0.55) : height * 0.6;
const bgDecorationTop = height - bgDecorationHeight;
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
const contentWidth = isTablet ? Math.min(640, Math.floor(width * 0.76)) : width;
const topImageWidth = isTablet ? Math.min(430, Math.floor(width * 0.48)) : 308;
const topImageHeight = isTablet ? Math.min(460, Math.floor(height * 0.45)) : 354;
const topImageMarginTop = isTablet ? 148 : 60;
const bgWidth = width + 10;
const bgLeft = -3;
const buttonSize = isTablet ? { width: 108, height: 70 } : { width: 87, height: 57 };
const bottomOffset = isTablet ? 28 : 60;
const buttonBottomGap = isTablet ? 28 : 40;
const noticeStyle = isTablet
? { fontSize: 14, lineHeight: 20, paddingHorizontal: 32, maxWidth: contentWidth }
: null;
const noticeLinkStyle = isTablet ? { fontSize: 14 } : null;
const titleStyle = isTablet ? { fontSize: 50, lineHeight: 60 } : null;
const subtitleStyle = isTablet ? { marginTop: 10, fontSize: 18, lineHeight: 24 } : null;
return (
<View style={styles.container}>
{/* 中间的背景装饰 SVG (现在放在上面,作为上层) */}
<View style={[styles.bgDecorationContainer, { top: bgDecorationTop }]}>
<FlowersBg width={width + 10} height={bgDecorationHeight} />
<View style={[styles.bgDecorationContainer, { bottom: 0, left: bgLeft }]}>
<FlowersBg width={bgWidth} height={bgDecorationHeight} preserveAspectRatio="none" />
</View>
{/* 顶部的花图片 (现在放在下面,作为下层) */}
<View style={styles.topImageContainer}>
<View style={[styles.topImageContainer, { marginTop: topImageMarginTop }]}>
<Image
source={require('../../assets/images/index/index_flowers.png')}
style={styles.topImage}
style={[styles.topImage, { width: topImageWidth, height: topImageHeight }]}
resizeMode="contain"
/>
</View>
{/* 文案内容 */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
<Text style={styles.titleText}>
{t('consent.title')}
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop, width: contentWidth }]}>
<Text style={[styles.titleText, titleStyle]}>
{title}
{'\n'}
{t('consent.subtitle')}
{subtitle}
</Text>
{subtitleSecondary ? (
<Text style={[styles.consentSubtitleSecondary, subtitleStyle]}>{subtitleSecondary}</Text>
) : null}
</View>
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
<SafeAreaView style={[styles.bottomContainer, { bottom: bottomOffset, width: contentWidth }]} edges={['bottom']}>
{showConsent && (
<>
<TouchableOpacity
onPress={handleAgree}
activeOpacity={0.8}
style={styles.buttonWrapper}
style={[styles.buttonWrapper, { marginBottom: buttonBottomGap }]}
accessibilityRole="button"
accessibilityLabel={t('consent.agree')}
>
<WelcomeBtn width={87} height={57} />
<WelcomeBtn width={buttonSize.width} height={buttonSize.height} />
</TouchableOpacity>
<Text style={styles.noticeText}>
<Text style={[styles.noticeText, noticeStyle]}>
<Trans
i18nKey="consent.noticeRich"
values={{
@@ -160,14 +199,14 @@ export default function SplashScreen() {
components={{
privacy: (
<Text
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
style={[styles.noticeLinkText, noticeLinkStyle, !links.privacy && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('privacy')}
suppressHighlighting
/>
),
terms: (
<Text
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
style={[styles.noticeLinkText, noticeLinkStyle, !links.terms && styles.noticeLinkTextDisabled]}
onPress={() => void handleOpenLegal('terms')}
suppressHighlighting
/>
@@ -185,11 +224,13 @@ export default function SplashScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
height: '100%',
alignSelf: 'stretch',
backgroundColor: '#F5D3B5', // 匹配 Figma 背景色
alignItems: 'center',
},
topImageContainer: {
marginTop: 60,
zIndex: 1, // 降低层级
},
topImage: {
@@ -198,7 +239,6 @@ const styles = StyleSheet.create({
},
bgDecorationContainer: {
position: 'absolute',
left: -3,
zIndex: 2, // 提高层级,使其覆盖在图片之上
},
contentContainer: {
@@ -213,10 +253,16 @@ const styles = StyleSheet.create({
fontWeight: '600',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
consentSubtitleSecondary: {
marginTop: 12,
fontSize: 16,
lineHeight: 22,
color: 'rgba(119, 47, 0, 0.6)',
textAlign: 'center',
fontFamily: Platform.OS === 'ios' ? 'STIX Two Text' : 'serif',
},
bottomContainer: {
position: 'absolute',
bottom: 60,
width: '100%',
alignItems: 'center',
zIndex: 4,
},

View File

@@ -1,18 +1,25 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { StyleSheet, useWindowDimensions } from 'react-native';
import { Text, View } from '@/components/Themed';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function NotFoundScreen() {
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 680, 24) : undefined;
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</View>
</>
);
@@ -24,6 +31,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
padding: 20,
width: '100%',
alignSelf: 'stretch',
},
contentWrap: {
width: '100%',
alignItems: 'center',
},
title: {
fontSize: 20,

View File

@@ -1,7 +1,7 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import * as Notifications from 'expo-notifications';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -11,7 +11,9 @@ import { Animated, AppState, Image, StyleSheet, View } 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';
import { getConsentAccepted, getOnboardingCompleted, getOrCreateClientUserId } from '@/src/storage/appStorage';
import { persistHomePushMessageFromResponse } from '@/src/services/pushNotificationRoute';
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
Notifications.setNotificationHandler({
@@ -75,6 +77,28 @@ export default function RootLayout() {
});
}, []);
useEffect(() => {
// 只要系统通知权限已经 granted就主动上报 Push Token不依赖用户在“每日提醒”里点确认
ensurePushTokenRegisteredIfPermitted()
.then((res) => {
if (__DEV__) console.log('[push_token_sync]', res);
})
.catch((e) => {
if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e);
});
}, []);
useEffect(() => {
// 兜底:当用户在系统弹窗/系统设置里变更权限后App 回到前台时再同步一次 token
const sub = AppState.addEventListener('change', (state) => {
if (state !== 'active') return;
ensurePushTokenRegisteredIfPermitted().catch(() => {
// ignore不阻塞
});
});
return () => sub.remove();
}, []);
useEffect(() => {
// 字体与 i18n 都准备好后,允许渲染 App原生 splash 的隐藏交给 onLayout避免“硬切/闪白”)
if (loaded && i18nReady) setAppReady(true);
@@ -113,7 +137,7 @@ export default function RootLayout() {
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
<View style={styles.splashOverlay}>
<Image
source={require('../assets/images/splashScreen.png')}
source={require('../assets/images/Screen_page.png')}
style={styles.splashImage}
resizeMode="contain"
/>
@@ -126,6 +150,7 @@ export default function RootLayout() {
function RootLayoutNav() {
const colorScheme = useColorScheme();
const router = useRouter();
useEffect(() => {
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
@@ -142,6 +167,44 @@ function RootLayoutNav() {
return () => sub.remove();
}, []);
const handleNotificationResponse = useCallback(
async (response: Notifications.NotificationResponse) => {
const message = await persistHomePushMessageFromResponse(response);
if (!message) return;
const [consentAccepted, onboardingCompleted] = await Promise.all([
getConsentAccepted(),
getOnboardingCompleted(),
]);
if (consentAccepted && onboardingCompleted) {
router.replace('/(app)/home');
}
},
[router]
);
useEffect(() => {
let cancelled = false;
Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (cancelled || !response) return;
return handleNotificationResponse(response);
})
.catch(() => {
// ignore通知冷启动读取失败不阻塞主流程
});
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
void handleNotificationResponse(response);
});
return () => {
cancelled = true;
sub.remove();
};
}, [handleNotificationResponse]);
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>

View File

@@ -1,14 +1,18 @@
import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { ActivityIndicator, StyleSheet, View, useWindowDimensions } from 'react-native';
import { useRouter } from 'expo-router';
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
/**
* 启动分发:根据 consent 和 onboarding 状态跳转
*/
export default function Index() {
const router = useRouter();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const loaderWidth = isTablet ? clampContentWidth(width, 680, 24) : undefined;
useEffect(() => {
let cancelled = false;
@@ -41,11 +45,24 @@ export default function Index() {
return (
<View style={styles.container}>
<ActivityIndicator />
<View style={[styles.loaderWrap, loaderWidth ? { width: loaderWidth } : null]}>
<ActivityIndicator />
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
container: {
flex: 1,
width: '100%',
height: '100%',
alignSelf: 'stretch',
alignItems: 'center',
justifyContent: 'center',
},
loaderWrap: {
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -1,15 +1,22 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import { Platform, StyleSheet, useWindowDimensions } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
import { clampContentWidth, isIPadLike } from '@/src/utils/device';
export default function ModalScreen() {
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? clampContentWidth(width, 700, 24) : undefined;
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
<View style={[styles.contentWrap, contentWidth ? { width: contentWidth } : null]}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
</View>
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
@@ -22,6 +29,12 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
alignSelf: 'stretch',
},
contentWrap: {
width: '100%',
alignItems: 'center',
},
title: {
fontSize: 20,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Dimensions } from 'react-native';
import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient';
import { Switch } from 'react-native';
@@ -41,9 +41,8 @@ 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');
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
import { isIPadLike } from '@/src/utils/device';
type Props = {
visible: boolean;
@@ -79,6 +78,12 @@ const NATURE_IMAGES = [
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = isIPadLike(width, height);
const contentWidth = isTablet ? width - 40 : width - 40;
const thumbWidth = isTablet ? Math.min(420, contentWidth - 120) : Math.min(width * 0.6, 300);
const widgetImageWidth = isTablet ? Math.min(520, contentWidth - 24) : width * 0.9;
const howToSlideWidth = isTablet ? Math.min(640, contentWidth) : width - 32;
const [page, setPage] = useState<Page>('root');
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
@@ -192,6 +197,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
{page === 'root' ? (
<RootPage
name={currentName}
contentWidth={contentWidth}
onOpenFavorites={() => go('favorites', 'forward')}
onOpenWidget={() => go('widget', 'forward')}
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
@@ -200,15 +206,15 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
onOpenTerms={() => openLink(legalLinks.terms)}
/>
) : page === 'favorites' ? (
<FavoritesPage visible={visible} page={page} />
<FavoritesPage visible={visible} page={page} thumbWidth={thumbWidth} contentWidth={contentWidth} />
) : page === 'dailyReminder' ? (
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} />
<DailyReminderPage visible={visible} onDone={() => go('root', 'back')} contentWidth={contentWidth} />
) : page === 'language' ? (
<LanguagePage />
<LanguagePage contentWidth={contentWidth} />
) : page === 'widgetHowTo' ? (
<WidgetHowToPage />
<WidgetHowToPage howToSlideWidth={howToSlideWidth} widgetImageWidth={widgetImageWidth} />
) : (
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} widgetImageWidth={widgetImageWidth} />
)}
</Animated.View>
</View>
@@ -222,6 +228,7 @@ function toastTodo(t: (key: string) => string) {
function RootPage({
name,
contentWidth,
onOpenFavorites,
onOpenWidget,
onOpenDailyReminder,
@@ -230,6 +237,7 @@ function RootPage({
onOpenTerms,
}: {
name?: string;
contentWidth: number;
onOpenFavorites: () => void;
onOpenWidget: () => void;
onOpenDailyReminder: () => void;
@@ -239,7 +247,7 @@ function RootPage({
}) {
const { t } = useTranslation();
return (
<>
<View style={[styles.sectionWrap, { width: contentWidth }]}>
<View style={styles.header}>
<AvatarIcon width={234} height={183} />
<Text style={styles.name}>{name || 'Hali'}</Text>
@@ -276,11 +284,23 @@ function RootPage({
onPress={onOpenLanguage}
/>
</View>
</>
<Text style={styles.versionText}>V1.0.0</Text>
</View>
);
}
function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
function FavoritesPage({
visible,
page,
thumbWidth,
contentWidth,
}: {
visible: boolean;
page: Page;
thumbWidth: number;
contentWidth: number;
}) {
const { t } = useTranslation();
const [favorites, setFavorites] = useState<(FavoriteItem & { text: string })[]>([]);
@@ -317,7 +337,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
}
return (
<View style={styles.favContainer}>
<View style={[styles.favContainer, { width: contentWidth, alignSelf: 'center' }]}>
{favorites.length === 0 ? (
<Text style={styles.favEmpty}>{t('favorites.empty')}</Text>
) : (
@@ -339,6 +359,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
<View style={styles.favRight}>
<View style={[
styles.favThumb,
{ width: thumbWidth },
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
]}>
{item.themeMode === 'scenery' ? (
@@ -346,7 +367,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
<Image
source={NATURE_IMAGES[parseInt(item.background)]}
style={{
width: width * 0.6,
width: thumbWidth,
height: 800, // 假设原图较高,设置一个较大的高度
position: 'absolute',
bottom: 0, // 关键:将图片底部对齐容器底部
@@ -378,7 +399,15 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
);
}
function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () => void }) {
function DailyReminderPage({
visible,
onDone,
contentWidth,
}: {
visible: boolean;
onDone: () => void;
contentWidth: number;
}) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [timesPerDay, setTimesPerDay] = useState(3);
@@ -430,14 +459,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
// 调试:打印状态
console.log('Push Permission Status:', status);
if (status === 'granted') {
if (status === 'granted' || (status as any) === 'provisional') {
setPushEnabled(true);
setHasSystemPermission(true);
// 获取 token 并上报后端(幂等)
try {
const expoPushToken = await getExpoPushTokenOrThrow();
await registerPushToken({ pushToken: expoPushToken });
await ensurePushTokenRegisteredIfPermitted();
// 偏好同步失败不应被用户感知为“开启失败”
// (常见现象:后端已接收 token但偏好接口短暂失败/超时)
try {
@@ -479,6 +507,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
await setDailyReminderSettings(next);
// 若系统权限已授予且用户意愿为开启:兜底同步一次 token避免“没点开关/没触发 toggle 导致后端无 token”
if (nextEnabled) {
ensurePushTokenRegisteredIfPermitted().catch(() => {
// ignore不阻塞保存
});
}
// 同步后端偏好(幂等;失败不阻塞)
try {
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
@@ -519,7 +554,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
</Pressable>
</View>
<View style={styles.remindRow}>
<View style={[styles.remindRow, { width: contentWidth }]}>
<View style={styles.rowLeft}>
<View style={styles.rowIcon}>
<RemindIcon width={18} height={18} />
@@ -550,9 +585,11 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
);
}
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
function WidgetPage({ onOpenHowTo, widgetImageWidth }: { onOpenHowTo: () => void; widgetImageWidth: number }) {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
const showLockScreenWidget = false;
// 根据语言选择图片
const widget1 = currentLang === 'en'
@@ -570,13 +607,15 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
</Pressable>
<View style={styles.widgetScroll}>
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
</Pressable>
{showLockScreenWidget ? (
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget1} style={[styles.widgetImg1, { width: widgetImageWidth, height: widgetImageWidth * (156 / 311) }]} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
</Pressable>
) : null}
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
<Image source={widget2} style={[styles.widgetImg2, { width: widgetImageWidth, height: widgetImageWidth * (175 / 311) }]} resizeMode="contain" />
<Text style={styles.widgetLabel}>{t('widget.homeScreen')}</Text>
</Pressable>
</View>
@@ -584,7 +623,7 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
);
}
function WidgetHowToPage() {
function WidgetHowToPage({ howToSlideWidth, widgetImageWidth }: { howToSlideWidth: number; widgetImageWidth: number }) {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
const flatListRef = useRef<FlatList>(null);
@@ -621,7 +660,7 @@ function WidgetHowToPage() {
const onScroll = (event: any) => {
const x = event.nativeEvent.contentOffset.x;
const index = Math.round(x / (width - 32));
const index = Math.round(x / howToSlideWidth);
if (index !== activeIndex) {
setActiveIndex(index);
}
@@ -645,14 +684,14 @@ function WidgetHowToPage() {
onScrollBeginDrag={onScrollBeginDrag}
scrollEventThrottle={16}
renderItem={({ item }) => (
<View style={styles.howToSlide}>
<Image source={item.src} style={styles.howToImg} resizeMode="contain" />
<View style={[styles.howToSlide, { width: howToSlideWidth }]}>
<Image source={item.src} style={[styles.howToImg, { width: widgetImageWidth, height: widgetImageWidth * (234 / 326) }]} resizeMode="contain" />
<Text style={styles.howToDesc}>{item.desc}</Text>
</View>
)}
/>
<View style={styles.pagination}>
<View style={[styles.pagination, { top: widgetImageWidth * (234 / 326) + 35 }]}>
{images.map((_, i) => (
<View
key={i}
@@ -667,7 +706,7 @@ function WidgetHowToPage() {
);
}
function LanguagePage() {
function LanguagePage({ contentWidth }: { contentWidth: number }) {
const { t, i18n } = useTranslation();
const currentLang = i18n.language;
@@ -677,8 +716,8 @@ function LanguagePage() {
];
return (
<View style={styles.langPage}>
<View style={styles.langList}>
<View style={[styles.langPage, { width: contentWidth, alignSelf: 'center' }]}>
<View style={[styles.langList, { width: contentWidth }]}>
{languages.map((lang, index) => (
<Pressable
key={lang.id}
@@ -740,6 +779,9 @@ const styles = StyleSheet.create({
pageWrap: {
// 给页面切换动画一个稳定的容器,避免布局抖动
},
sectionWrap: {
alignSelf: 'center',
},
backRow: {
alignSelf: 'flex-start',
paddingVertical: 4,
@@ -796,6 +838,13 @@ const styles = StyleSheet.create({
overflow: 'hidden',
marginBottom: 8,
},
versionText: {
marginTop: 12,
textAlign: 'center',
color: 'rgba(94,42,40,0.45)',
fontSize: 12,
fontWeight: '500',
},
item: {
height: 52,
paddingHorizontal: 18,
@@ -838,7 +887,7 @@ const styles = StyleSheet.create({
paddingVertical: 100,
},
favList: {
paddingHorizontal: 20,
paddingHorizontal: 8,
paddingBottom: 80,
},
favCard: {
@@ -862,7 +911,6 @@ const styles = StyleSheet.create({
backgroundColor: '#FFF4EA',
borderRadius: 16,
padding: 20,
width: width * 0.6,
height: 161,
justifyContent: 'center',
position: 'relative',
@@ -935,7 +983,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 18,
width: width - 40, // 屏幕宽度减去左右各 20pt
alignSelf: 'center',
},
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
@@ -994,12 +1041,12 @@ const styles = StyleSheet.create({
width: '100%',
},
widgetImg1: {
width: width * 0.9,
height: (width * 0.9) * (156 / 311),
width: 320,
height: 160,
},
widgetImg2: {
width: width * 0.9,
height: (width * 0.9) * (175 / 311),
width: 320,
height: 176,
},
widgetLabel: {
marginTop: 12,
@@ -1013,12 +1060,12 @@ const styles = StyleSheet.create({
paddingTop: 20,
},
howToSlide: {
width: width - 32, // 减去 SheetModal 的 paddingHorizontal: 16 * 2
width: 320,
alignItems: 'center',
},
howToImg: {
width: width * 0.9,
height: (width * 0.9) * (234 / 326),
width: 320,
height: 230,
marginBottom: 40,
},
howToDesc: {
@@ -1032,7 +1079,7 @@ const styles = StyleSheet.create({
pagination: {
flexDirection: 'row',
position: 'absolute',
top: (width * 0.9) * (234 / 326) + 35, // 根据新的图片高度动态计算
top: 265,
gap: 8,
},
dot: {
@@ -1054,7 +1101,6 @@ const styles = StyleSheet.create({
backgroundColor: '#FFFFFF',
borderRadius: 20,
overflow: 'hidden',
width: width - 40, // 屏幕宽度减去左右各 20pt
alignSelf: 'center',
},
langItem: {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { Image, Pressable, StyleSheet, Text, View, Platform, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import SheetModal from '@/components/ui/SheetModal';
@@ -15,8 +15,11 @@ type Props = {
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
return (
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={isTablet ? 560 : 360}>
<View style={styles.row}>
<ThemeCard
title={t('theme.scenery')}
@@ -100,9 +103,9 @@ const styles = StyleSheet.create({
row: {
flexDirection: 'row',
flexWrap: 'nowrap',
gap: 12,
paddingHorizontal: 4,
paddingBottom: 50,
gap: 8,
paddingHorizontal: 0,
paddingBottom: 22,
paddingTop: 20,
justifyContent: 'space-between',
},

View File

@@ -13,7 +13,7 @@ export default function WidgetModal({ visible, onClose }: Props) {
const { t } = useTranslation();
return (
<SheetModal visible={visible} title={t('profile.widget')} onClose={onClose}>
<SheetModal visible={visible} title={t('widget.howToTitle')} onClose={onClose}>
<View style={styles.content}>
<View style={styles.row}>
<PreviewCard label={t('widget.lockScreen')}>

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
@@ -16,10 +16,13 @@ interface NameInputStepProps {
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const [isFocused, setIsFocused] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const blinkAnim = useRef(new Animated.Value(1)).current;
const hasInput = value.trim().length > 0;
const inputCardWidth = isTablet ? Math.min(520, Math.floor(width * 0.72)) : 335;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
@@ -62,7 +65,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
return (
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
<View style={styles.inputCard}>
<View style={[styles.inputCard, { width: inputCardWidth }]}>
<View style={styles.inputWrapper}>
{/* 显示层:文案 + 跟随的光标 */}
<View style={styles.displayLayer}>
@@ -95,8 +98,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
blurOnSubmit={true}
onSubmitEditing={() => {
Keyboard.dismiss();
// 有输入时,“完成”直接进入下一步,避免真机卡在键盘上
if (value.trim().length > 0) onNext();
// 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️
}}
/>
</View>

View File

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

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import AddIcon from '@/assets/images/icon/add_icon.svg';
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
@@ -11,16 +12,21 @@ interface ReminderStepProps {
value: number;
onChange: (value: number) => void;
onFinish: () => void;
onSkip: () => void;
onSkip?: () => void;
/** 完成后请求通知权限时的加载态 */
loading?: boolean;
}
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const contentMaxWidth = isTablet ? 560 : undefined;
const handleReduce = () => {
// 允许 050 表示关闭每日提醒
if (value > 0) onChange(value - 1);
// 本页最小为 1不接收提醒请使用右上角 Skip
if (value > 1) onChange(value - 1);
};
const handleAdd = () => {
@@ -29,28 +35,39 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep
return (
<View style={styles.container}>
<View style={styles.counterContainer}>
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
<View style={[styles.counterContainer, contentMaxWidth ? { maxWidth: contentMaxWidth } : null]}>
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
<ReduceIcon width={47} height={47} />
</TouchableOpacity>
<View style={styles.numberWrapper}>
<Text style={styles.numberText}>{value}</Text>
<Text style={styles.unitText}>{t('dailyReminder.timesUnit')}</Text>
<Text style={styles.unitText}>
{value === 1 ? t('dailyReminder.timesUnitSingular') : t('dailyReminder.timesUnit')}
</Text>
</View>
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
<AddIcon width={47} height={47} />
</TouchableOpacity>
</View>
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
<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 onPress={onFinish} disabled={loading} activeOpacity={0.8}>
<View style={styles.finishWrap}>
{loading ? (
<LinearGradient
colors={['#F69F7B', '#F99CC0']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.loadingPill, styles.finishDisabled]}
>
<ActivityIndicator size="small" color="#FFFFFF" />
</LinearGradient>
) : (
<BtnClicked width={87} height={57} />
)}
</View>
</TouchableOpacity>
</View>
</View>
@@ -93,17 +110,21 @@ const styles = StyleSheet.create({
footer: {
position: 'absolute',
alignItems: 'center',
}
,
skipBtn: {
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 18,
},
skipText: {
color: OnboardingColors.textPrimary,
fontSize: 15,
fontWeight: '600',
opacity: 0.85,
finishWrap: {
width: 87,
height: 57,
alignItems: 'center',
justifyContent: 'center',
},
finishDisabled: {
opacity: 0.7,
},
loadingPill: {
width: 87,
height: 57,
borderRadius: 28.5,
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { View, StyleSheet, TouchableOpacity, ScrollView, Text, Platform, useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { OnboardingColors } from '@/constants/OnboardingTheme';
import { SerifText } from './SerifText';
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
import { OnboardingColors, OnboardingFont } from '@/constants/OnboardingTheme';
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
@@ -21,34 +19,43 @@ interface SelectionStepProps {
}
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
const { width, height } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(width, height) >= 768;
const maxOptionWidth = isTablet ? 560 : undefined;
const hasSelection = selectedIds.length > 0;
const insets = useSafeAreaInsets();
const footerBottom = insets.bottom + 16;
const footerBottom = insets.bottom + (isTablet ? 38 : 28);
const footerButtonHeight = 57;
const footerPaddingBottom = footerBottom + footerButtonHeight + 24;
// 底部留白加大,避免最后一项与按钮边框视觉重叠
const footerPaddingBottom = footerBottom + footerButtonHeight + 40;
return (
<View style={styles.container}>
<ScrollView
style={styles.scroll}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
contentContainerStyle={[
styles.optionsList,
{
paddingBottom: footerPaddingBottom,
alignItems: 'center',
},
]}
>
{options.map((option) => {
const isSelected = selectedIds.includes(option.id);
return (
<TouchableOpacity
key={option.id}
style={styles.optionCard}
style={[
styles.optionCard,
maxOptionWidth ? { maxWidth: maxOptionWidth } : null,
isSelected && styles.optionCardSelected,
]}
onPress={() => onToggle(option.id)}
activeOpacity={0.7}
>
<SerifText style={styles.optionText}>{option.label}</SerifText>
{isSelected && (
<View style={styles.iconWrapper}>
<SelectedIcon width={20} height={20} />
</View>
)}
<Text style={styles.optionText}>{option.label}</Text>
</TouchableOpacity>
);
})}
@@ -67,7 +74,7 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
paddingTop: 8,
},
scroll: {
flex: 1,
@@ -82,7 +89,7 @@ const styles = StyleSheet.create({
borderRadius: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
justifyContent: 'center',
paddingHorizontal: 24,
marginBottom: 12,
shadowColor: '#000',
@@ -91,14 +98,14 @@ const styles = StyleSheet.create({
shadowRadius: 10,
elevation: 2,
},
optionCardSelected: {
backgroundColor: OnboardingColors.cardSelected,
},
optionText: {
fontSize: 18,
color: OnboardingColors.textPrimary,
fontWeight: '500',
flex: 1,
},
iconWrapper: {
marginLeft: 10,
fontFamily: OnboardingFont.question,
},
footer: {
position: 'absolute',

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Image, ImageSourcePropType, Platform, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Animated, {
@@ -10,7 +10,6 @@ import Animated, {
withTiming,
} from 'react-native-reanimated';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const FIXED_TOP_GAP = 100; // 统一距离顶部的高度
type Props = {
@@ -30,11 +29,13 @@ type Props = {
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const isTablet = Platform.OS === 'ios' && Math.min(windowWidth, windowHeight) >= 768;
const [mounted, setMounted] = useState(false);
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
const dragY = useSharedValue(0); // 拖拽位移
const sheetHeight = customHeight || (SCREEN_HEIGHT - FIXED_TOP_GAP);
const sheetHeight = customHeight || (isTablet ? Math.min(windowHeight - 72, 760) : (windowHeight - FIXED_TOP_GAP));
useEffect(() => {
if (visible) {
@@ -90,7 +91,10 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
};
});
const containerPaddingBottom = useMemo(() => Math.max(insets.bottom, 80), [insets.bottom]); // 增加底部间距至 80约占 350 高度的 22%,确保内容不被截断并留出足够呼吸感
const containerPaddingBottom = useMemo(() => {
if (customHeight) return Math.max(insets.bottom, 16);
return Math.max(insets.bottom, isTablet ? 20 : 80);
}, [customHeight, insets.bottom, isTablet]);
// 注意Modal 的 visible 必须为 true 才会渲染,因此用 mounted 保持退场动画
return (
@@ -107,6 +111,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
{...panResponder.panHandlers}
style={[
styles.sheet,
isTablet ? styles.sheetTablet : null,
sheetStyle,
{
height: sheetHeight,
@@ -159,6 +164,14 @@ const styles = StyleSheet.create({
paddingTop: 8,
paddingHorizontal: 16,
},
sheetTablet: {
width: '100%',
alignSelf: 'stretch',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
handleContainer: {
alignItems: 'center',
paddingVertical: 8,

View File

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

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,13 +11,13 @@
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 */; };
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A4D /* Screen_page.png */; };
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A4D /* Screen_page.png */; };
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -49,17 +49,17 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* HeyMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HeyMama.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07F961A680F5B00A75B9A /* DearMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DearMama.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = client/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
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>"; };
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; 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>"; };
E3328F0E595C1F4A244DF238 /* libPods-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-client.a"; sourceTree = BUILT_PRODUCTS_DIR; };
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -74,7 +74,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
EmotionWidget.swift,
@@ -85,18 +85,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -186,7 +175,7 @@
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* HeyMama.app */,
13B07F961A680F5B00A75B9A /* DearMama.app */,
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
);
name = Products;
@@ -213,7 +202,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -250,7 +239,7 @@
);
name = client;
productName = client;
productReference = 13B07F961A680F5B00A75B9A /* HeyMama.app */;
productReference = 13B07F961A680F5B00A75B9A /* DearMama.app */;
productType = "com.apple.product-type.application";
};
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
@@ -301,6 +290,7 @@
knownRegions = (
en,
Base,
"zh-Hant",
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
@@ -481,7 +471,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -526,7 +516,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama;
PRODUCT_NAME = DearMama;
SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -535,7 +525,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -567,7 +557,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = HeyMama;
PRODUCT_NAME = DearMama;
SKIP_INSTALL = NO;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -575,7 +565,7 @@
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
@@ -755,7 +745,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
@@ -807,7 +797,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};

View File

@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BuildableName = "DearMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
@@ -44,7 +44,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BuildableName = "DearMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
@@ -61,7 +61,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BuildableName = "DearMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>
@@ -72,7 +72,7 @@
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
customArchiveName = "Hey Mama"
customArchiveName = "Dear Mama"
revealArchiveInOrganizer = "YES">
<PostActions>
<ExecutionAction
@@ -85,7 +85,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HeyMama.app"
BuildableName = "DearMama.app"
BlueprintName = "client"
ReferencedContainer = "container:client.xcodeproj">
</BuildableReference>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hey Mama</string>
<string>Dear Mama</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -38,8 +38,6 @@
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
@@ -47,6 +45,8 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
@@ -60,6 +60,8 @@
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
@@ -72,8 +74,6 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>

View File

@@ -42,7 +42,7 @@ if [[ -z "$APP_PLIST" ]]; then
fi
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 HeyMama.app
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 DearMama.app
APP_REL_PATH="Applications/$APP_NAME"
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"

View File

@@ -9,7 +9,7 @@ private let keyWidgetConfig = "widget.config.v1"
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
private let keyWidgetDailyReco = "widget.dailyReco.v1"
private let fallbackTextTC = "你已很努力了,今天也值得被温柔对待。"
private let fallbackTextTC = "你已很努力了,今天也值得被溫柔對待。"
private let fallbackTextEN = "Youve been doing great — you deserve kindness today."
private func defaults() -> UserDefaults? {
@@ -29,17 +29,24 @@ private func localDayKey(_ date: Date = Date()) -> String {
}
private func resolveLang() -> String {
// en/tc
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
return preferred.hasPrefix("zh") ? "tc" : "en"
// en/tc
// - Hant / TW / HK / MO=> tc
// - zh-Hans / zh-CN=> en
let preferred = (Locale.preferredLanguages.first ?? "en").lowercased()
if preferred.hasPrefix("zh-hant") { return "tc" }
if preferred.hasPrefix("zh-tw") { return "tc" }
if preferred.hasPrefix("zh-hk") { return "tc" }
if preferred.hasPrefix("zh-mo") { return "tc" }
return "en"
}
private func resolveTitle(lang: String) -> String {
lang == "en" ? "Mindfulness" : "正念"
// Dear Mama
return "Dear Mama"
}
private func resolveFooterHint(lang: String) -> String {
lang == "en" ? "Tap to open the app" : "我回到 App"
lang == "en" ? "Tap to open the app" : "我回到 App"
}
private func joinUrl(base: String, path: String) -> String {
@@ -76,16 +83,44 @@ private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
defaults()?.set(raw, forKey: key)
}
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
private func readCachedText(family: WidgetFamily) -> (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)
if let item = d["item"] as? [String: Any] {
if let text = pickWidgetText(item: item, family: family), !text.isEmpty {
return (dayKey: dayKey, lang: lang, text: text)
}
}
return nil
}
private func familyKey(_ family: WidgetFamily) -> String {
switch family {
case .systemSmall:
return "small"
case .systemMedium:
return "medium"
case .systemLarge:
return "large"
default:
return "small"
}
}
private func pickWidgetText(item: [String: Any], family: WidgetFamily?) -> String? {
// 使 App wrapped_text_by_family退 raw text
if let family = family,
let wrappedByFamily = item["wrapped_text_by_family"] as? [String: Any] {
let key = familyKey(family)
if let v = wrappedByFamily[key] as? String, !v.isEmpty {
return v
}
}
if let raw = item["text"] as? String, !raw.isEmpty { return raw }
return nil
}
private func readApiBaseUrl() -> String? {
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
let base = d["apiBaseUrl"] as? String
@@ -174,7 +209,7 @@ struct EmotionProvider: TimelineProvider {
let today = localDayKey(Date())
// 1)
if let cached = readCachedText(), cached.dayKey == today {
if let cached = readCachedText(family: context.family), cached.dayKey == today {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
@@ -201,7 +236,7 @@ struct EmotionProvider: TimelineProvider {
}
// 3)
if let cached = readCachedText() {
if let cached = readCachedText(family: context.family) {
let entry = EmotionEntry(
date: Date(),
lang: cached.lang,
@@ -245,12 +280,12 @@ struct EmotionWidgetView: View {
Text(entry.text)
.font(fontForFamily())
.foregroundColor(widgetTextColor)
.multilineTextAlignment(.leading)
.multilineTextAlignment(.center)
.lineSpacing(lineSpacingForFamily())
.lineLimit(lineLimitForFamily())
.minimumScaleFactor(0.78)
.padding(paddingForFamily())
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.widgetSolidBackground(widgetBackgroundColor)
.widgetURL(deepLink)
}
@@ -332,8 +367,9 @@ struct EmotionWidget: Widget {
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
EmotionWidgetView(entry: entry)
}
.configurationDisplayName("情绪小组件")
.description("一段温柔提醒,陪你回到当下。")
// /使 Widget Extension Localizable.strings
.configurationDisplayName("WIDGET_DISPLAY_NAME")
.description("WIDGET_DESCRIPTION")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}

View File

@@ -0,0 +1,3 @@
"WIDGET_DISPLAY_NAME" = "Emotion Widget";
"WIDGET_DESCRIPTION" = "A gentle reminder to return to the present.";

View File

@@ -0,0 +1,3 @@
"WIDGET_DISPLAY_NAME" = "情緒小組件";
"WIDGET_DESCRIPTION" = "一段溫柔提醒,陪你回到當下。";

View File

@@ -1,5 +1,6 @@
// Metro 配置:支持 import 本地 .svg 为 React 组件
// 说明Expo SDK 54 + react-native-svg-transformer 的常见配置方式
const path = require('path');
const { getDefaultConfig } = require('expo/metro-config');
/** @type {import('expo/metro-config').MetroConfig} */
@@ -14,6 +15,10 @@ config.resolver = {
...config.resolver,
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...config.resolver.sourceExts, 'svg'],
// 确保 react-native-text-size 从项目 node_modules 解析(避免 Metro 解析不到)
extraNodeModules: {
'react-native-text-size': path.resolve(__dirname, 'node_modules/react-native-text-size'),
},
};
module.exports = config;

134
client/package-lock.json generated
View File

@@ -1543,6 +1543,7 @@
"version": "2.0.17",
"resolved": "https://registry.npmmirror.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
@@ -1558,6 +1559,7 @@
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
@@ -1574,6 +1576,7 @@
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -1590,6 +1593,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -1606,6 +1610,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -1622,6 +1627,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -1638,6 +1644,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -1654,6 +1661,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -1670,6 +1678,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -1686,6 +1695,7 @@
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1702,6 +1712,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1718,6 +1729,7 @@
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1734,6 +1746,7 @@
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1750,6 +1763,7 @@
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1766,6 +1780,7 @@
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1782,6 +1797,7 @@
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1798,6 +1814,7 @@
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1814,6 +1831,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -1830,6 +1848,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -1846,6 +1865,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -1862,6 +1882,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -1878,6 +1899,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -1894,6 +1916,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
@@ -1910,6 +1933,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
@@ -1926,6 +1950,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -1942,6 +1967,7 @@
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -1958,6 +1984,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3224,6 +3251,7 @@
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -3237,6 +3265,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
@@ -3250,6 +3279,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -3263,6 +3293,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -3276,6 +3307,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -3289,6 +3321,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -3302,6 +3335,7 @@
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3315,6 +3349,7 @@
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3328,6 +3363,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3341,6 +3377,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3354,6 +3391,7 @@
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3367,6 +3405,7 @@
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3380,6 +3419,7 @@
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3393,6 +3433,7 @@
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3406,6 +3447,7 @@
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3419,6 +3461,7 @@
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3432,6 +3475,7 @@
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3445,6 +3489,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3458,6 +3503,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3471,6 +3517,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -3484,6 +3531,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
@@ -3497,6 +3545,7 @@
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3510,6 +3559,7 @@
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3523,6 +3573,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3536,6 +3587,7 @@
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3569,7 +3621,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
@@ -3860,6 +3913,7 @@
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
@@ -3869,13 +3923,15 @@
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
@@ -3889,7 +3945,8 @@
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmmirror.com/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
@@ -3989,6 +4046,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
@@ -4006,6 +4064,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
@@ -4032,6 +4091,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
@@ -4044,6 +4104,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
@@ -4057,6 +4118,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
@@ -4071,6 +4133,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
@@ -4080,6 +4143,7 @@
"resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
@@ -4147,6 +4211,7 @@
"version": "8.17.1",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4285,6 +4350,7 @@
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
@@ -4820,6 +4886,7 @@
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
@@ -5601,7 +5668,8 @@
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@@ -5621,6 +5689,7 @@
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
@@ -5701,6 +5770,7 @@
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
@@ -5734,6 +5804,7 @@
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
@@ -5832,6 +5903,7 @@
"version": "15.0.8",
"resolved": "https://registry.npmmirror.com/expo-crypto/-/expo-crypto-15.0.8.tgz",
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
@@ -5843,6 +5915,7 @@
"version": "6.0.20",
"resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-6.0.20.tgz",
"integrity": "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==",
"license": "MIT",
"dependencies": {
"expo-dev-launcher": "6.0.20",
"expo-dev-menu": "7.0.18",
@@ -5858,6 +5931,7 @@
"version": "6.0.20",
"resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz",
"integrity": "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.11.0",
"expo-dev-menu": "7.0.18",
@@ -5871,6 +5945,7 @@
"version": "7.0.18",
"resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz",
"integrity": "sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA==",
"license": "MIT",
"dependencies": {
"expo-dev-menu-interface": "2.0.0"
},
@@ -5882,6 +5957,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz",
"integrity": "sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
@@ -5890,6 +5966,7 @@
"version": "8.0.10",
"resolved": "https://registry.npmmirror.com/expo-device/-/expo-device-8.0.10.tgz",
"integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==",
"license": "MIT",
"dependencies": {
"ua-parser-js": "^0.7.33"
},
@@ -5915,6 +5992,7 @@
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
@@ -5949,7 +6027,8 @@
"node_modules/expo-json-utils": {
"version": "0.15.0",
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ=="
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
"license": "MIT"
},
"node_modules/expo-keep-awake": {
"version": "15.0.8",
@@ -6003,6 +6082,7 @@
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-1.0.10.tgz",
"integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==",
"license": "MIT",
"dependencies": {
"@expo/config": "~12.0.11",
"expo-json-utils": "~0.15.0"
@@ -6351,6 +6431,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
"integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
@@ -6543,7 +6624,8 @@
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
],
"license": "BSD-3-Clause"
},
"node_modules/fb-watchman": {
"version": "2.0.2",
@@ -6962,6 +7044,7 @@
"version": "3.3.2",
"resolved": "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
@@ -6969,7 +7052,8 @@
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": {
"version": "7.0.2",
@@ -7623,7 +7707,8 @@
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
@@ -8086,6 +8171,7 @@
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
@@ -8784,7 +8870,8 @@
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
]
],
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.3.0",
@@ -9132,7 +9219,8 @@
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
@@ -9522,6 +9610,7 @@
"version": "2.30.0",
"resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-2.30.0.tgz",
"integrity": "sha512-5YsnKHGa0X9C8lb5oCnKm0fLUPM6CRduvUUw2Bav4RIj/C3HcFh4RIUnF8wgG6JQWCL1//gRx4v+LVWgcIQdGA==",
"license": "MIT",
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
@@ -9630,6 +9719,7 @@
"version": "4.0.0-rc.1",
"resolved": "https://registry.npmmirror.com/react-native-text-size/-/react-native-text-size-4.0.0-rc.1.tgz",
"integrity": "sha512-CysqjU2jK6Yc+a+kEI222pUyTY2ywcU2HqbFqf1KHymW6OPTdvBBHqbEJKL0QiLhQaFYDbqicM+h990s9TP00g==",
"license": "BSD-2-Clause",
"peerDependencies": {
"react-native": ">=0.59.0"
}
@@ -10122,6 +10212,7 @@
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10417,7 +10508,8 @@
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
@@ -10562,7 +10654,8 @@
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/stackframe": {
"version": "1.3.4",
@@ -10595,7 +10688,8 @@
"version": "3.10.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/stream-buffers": {
"version": "2.2.0",
@@ -10942,13 +11036,15 @@
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
@@ -11003,6 +11099,7 @@
"resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -11521,6 +11618,7 @@
"resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11595,6 +11693,7 @@
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
@@ -11612,6 +11711,7 @@
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@@ -11638,6 +11738,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11652,6 +11753,7 @@
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -11729,6 +11831,7 @@
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@@ -11861,6 +11964,7 @@
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"

View File

@@ -4,10 +4,14 @@
"version": "1.0.0",
"scripts": {
"start": "expo start",
"start:clean": "expo start -c",
"android": "expo run:android",
"ios": "expo run:ios --scheme \"Hey Mama\"",
"ios": "expo run:ios --scheme \"Dear Mama\"",
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Dear Mama\"",
"web": "expo start --web",
"test": "vitest run"
"test": "vitest run",
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
"clean:ios-build": "rm -rf ~/Library/Developer/Xcode/DerivedData/client-* 2>/dev/null; echo 'Cleared Xcode DerivedData for client'"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",

View File

@@ -27,7 +27,7 @@ export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
* 默认回退到 prod避免误打到 localhost 导致真机“无法发起网络请求”)。
*/
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'dev';
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;

View File

@@ -20,11 +20,12 @@ type TextSizeMeasureParams = {
type TextSizeMeasureResult = { width: number };
async function loadReactNativeTextSize(): Promise<{
function loadReactNativeTextSize(): {
measure: (params: TextSizeMeasureParams) => Promise<TextSizeMeasureResult>;
}> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import('react-native-text-size');
} {
// 使用 require 确保 Metro 能解析并打包该原生模块(动态 import 在某些环境下无法被正确解析)
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
const mod: any = require('react-native-text-size');
return mod?.default ?? mod;
}
@@ -42,7 +43,7 @@ function toTextSizeFontSpecs(fontSpec: FontSpec): Pick<TextSizeMeasureParams, 'f
* - usePreciseWidth=true取更精确的宽度开销更大但对本算法更稳定
*/
export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => {
const TextSize = await loadReactNativeTextSize();
const TextSize = loadReactNativeTextSize();
if (!TextSize || typeof TextSize.measure !== 'function') {
// 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。
// 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。

View File

@@ -1,5 +1,6 @@
import type { MeasureWidthImpl } from './measure/types';
import type { ScoreTerm } from './scoring/types';
import type { Weights } from './scoring/types';
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
export type LineMode = 'AUTO' | 'FIXED';
@@ -26,6 +27,21 @@ export type WrapTextInput = {
fontSpec?: Partial<FontSpecInput> | null;
/** 可选注入测量实现APP 场景强烈建议提供WIDGET 默认不启用) */
measureWidthImpl?: MeasureWidthImpl;
/**
* 可选评分偏好(用于 UI 场景“更好看”的排版风格)。
* - 不传:使用算法默认 v1 权重与口径(更贴近文档 10.3
* - 传入:仅在本次 wrapText 调用内生效,不影响全局
*/
scoringOverrides?: {
/** 覆盖/微调默认权重(整数)。建议只改少数项,例如 TC 的标点断行奖励。 */
weights?: Partial<Weights>;
/** 覆盖理想行宽比例0~1。更小会更倾向“提前换行”。 */
idealWidthRatio?: Partial<Record<TextWrapContext, number>>;
/** 最短偏好比例(相对 idealWidth。更小会更宽容“短行”。 */
minPreferredRatio?: number;
/** 末行过短惩罚阈值比例(相对 idealWidth。更小会更宽容“短末行”。 */
shortLastLineRatio?: number;
};
/**
* 测量缓存隔离 key可选
* 口径建议:`APP|ios|<scale?>` / `WIDGET|small`。

View File

@@ -3,6 +3,7 @@ import { normalizeWhitespace, tokenizeEN } from './core/index';
import { segmentGraphemes } from './grapheme/index';
import { generateBreakpoints } from './breakpoints/index';
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from './scoring/index';
import { mergeWeights } from './scoring/weights';
import { searchBestLayoutApp } from './searchApp/index';
import { searchBestLayoutWidget } from './searchWidget/index';
import { applyOverflowFallback } from './overflow/index';
@@ -45,12 +46,23 @@ export async function wrapText(input: WrapTextInput): Promise<WrapTextOutput> {
// scoringprotectedPhrases 从 constraints 注入
const lexicons = { ...DEFAULT_LEXICONS, protectedPhrases: input.constraints?.protectedPhrases ?? DEFAULT_LEXICONS.protectedPhrases };
const tcPunctuations: string[] = ['', '。', '', '', '', '', '、'];
// 评分偏好(可选):用于 UI 侧“更好看”的排版风格微调
const scoringOverrides = input.scoringOverrides ?? null;
const weights = scoringOverrides?.weights ? mergeWeights(scoringOverrides.weights) : DEFAULT_WEIGHTS;
const idealWidthRatio = {
APP: scoringOverrides?.idealWidthRatio?.APP ?? 0.9,
WIDGET: scoringOverrides?.idealWidthRatio?.WIDGET ?? 0.95,
};
const scoringConfig = {
weights: DEFAULT_WEIGHTS,
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
weights,
idealWidthRatio,
ellipsisToken: '…',
tcParticleWhitelist: [],
tcPunctuations,
// 下面两项默认由 score.ts 内部给出;此处仅在有 overrides 时注入
minPreferredRatio: scoringOverrides?.minPreferredRatio,
shortLastLineRatio: scoringOverrides?.shortLastLineRatio,
};
// 搜索

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
"progress": "{{current}}/{{total}}",
"next": "Next",
"skip": "Skip",
"skipAll": "Skip onboarding",
"skipAll": "Skip",
"q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?",
@@ -25,10 +25,11 @@
"q4Desc": "You can skip. Well stay with you along the way."
},
"onboardingSurvey": {
"greeting": "Hi {{name}},",
"steps": {
"name": { "title": "What do you want to be called?", "placeholder": "Mama" },
"name": { "title": "What should we call you?", "placeholder": "Mama" },
"status": {
"title": "Which stage of motherhood are you in?",
"title": "Where are you at right now?",
"options": {
"pregnant": "Pregnant / Preparing",
"has_kids": "Parenting",
@@ -66,7 +67,7 @@
"balance": "Rest & balance"
}
},
"reminder": { "title": "How many reminders do you want per day?" }
"reminder": { "title": "How often would you like a gentle reminder?" }
}
},
"intent": {
@@ -87,7 +88,7 @@
"errorDesc": "Its okay if enabling fails. You can keep using the app."
},
"home": {
"title": "Mindfulness",
"title": "Dear Mama",
"like": "Like",
"dislike": "Dislike",
"favorites": "Favorites",
@@ -115,6 +116,7 @@
"dailyReminder": {
"title": "Daily Reminder",
"timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder",
"ok": "Ok",
"minus": "Decrease",
@@ -125,7 +127,7 @@
"homeScreen": "Home Screen Widget",
"howToTitle": "How to add the widget",
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.",
"howToDesc2": "Search “Dear Mama”, choose a widget size you like, then tap “Add Widget”.",
"previewDate": "Thu, Jan 29",
"previewQuote": "Im proud of who I am, even while becoming who I want to be."
},
@@ -139,16 +141,17 @@
"language": "Language",
"version": "Version",
"widgetTitle": "iOS Widget",
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Dear Mama” → add a size you like."
},
"consent": {
"title": "You Are Perfect.",
"subtitle": "Everything\nWill Be Better.",
"title": "Dear mama.",
"subtitle": "Youre doing okay\nright now.",
"subtitleSecondary": "",
"agree": "Agree & Continue",
"privacy": "Privacy Policy",
"terms": "Terms of Use",
"notice": "By continuing, you agree to the Privacy Policy and Terms of Use.",
"noticeRich": "By continuing, you agree to the <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
"noticeRich": "By continuing,\nyou agree to the <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
"linkUnavailable": "Failed to load the policy link. Please check your network and try again.",
"linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}",
"linkLoadingSuffix": " (loading…)"
@@ -183,7 +186,7 @@
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳過",
"skipAll": "跳過整個引導",
"skipAll": "跳過",
"q1Title": "你最近的感受更接近哪一種?",
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
"q2Title": "你更希望獲得哪種支持?",
@@ -194,18 +197,19 @@
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
},
"onboardingSurvey": {
"greeting": "Hi {{name}}",
"steps": {
"name": { "title": "我可以怎麼稱呼你?", "placeholder": "媽媽" },
"name": { "title": "怎麼稱呼你", "placeholder": "媽媽" },
"status": {
"title": "媽媽的狀態",
"title": "你現在正處在哪個階段呢",
"options": {
"pregnant": "懷孕中/準備成為媽媽",
"pregnant": "懷孕中/正在準備迎接寶寶",
"has_kids": "已經有孩子",
"no_fill": "不想填寫"
"no_fill": "我暫時不想說"
}
},
"emotion": {
"title": "當下情緒狀態",
"title": "今天的你,還好嗎",
"options": {
"happy": "愉悅、滿足",
"calm": "平靜、安穩",
@@ -256,7 +260,7 @@
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
},
"home": {
"title": "正念",
"title": "Dear Mama",
"like": "喜歡",
"dislike": "不喜歡",
"favorites": "收藏",
@@ -284,6 +288,7 @@
"dailyReminder": {
"title": "每日提醒",
"timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒",
"ok": "確定",
"minus": "減少次數",
@@ -292,9 +297,9 @@
"widget": {
"lockScreen": "鎖屏小工具",
"homeScreen": "桌面小工具",
"howToTitle": "如何加小工具",
"howToTitle": "如何加小工具",
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
"howToDesc2": "搜尋「Dear Mama」,選擇喜歡的尺寸,點「加入小工具」。",
"previewDate": "1月29日週四 · 已至臘月十一",
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
},
@@ -308,11 +313,12 @@
"language": "語言",
"version": "版本",
"widgetTitle": "iOS 小工具",
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Dear Mama」 → 添加你喜歡的尺寸。"
},
"consent": {
"title": "你很完美。",
"subtitle": "一切\n都會更好。",
"title": "我們知道,",
"subtitle": "當媽媽很不容易。",
"subtitleSecondary": "這裡給你一些溫柔的肯定與提醒",
"agree": "同意並繼續",
"privacy": "隱私協議",
"terms": "用戶使用協議",

View File

@@ -11,7 +11,7 @@
"progress": "{{current}}/{{total}}",
"next": "Next",
"skip": "Skip",
"skipAll": "Skip onboarding",
"skipAll": "Skip",
"q1Title": "How are you feeling lately?",
"q1Desc": "No right or wrong. You can skip and adjust later.",
"q2Title": "What kind of support do you want?",
@@ -32,7 +32,7 @@
"errorDesc": "Its okay if enabling fails. You can keep using the app."
},
"home": {
"title": "Mindfulness",
"title": "Dear Mama",
"like": "Like",
"dislike": "Dislike",
"favorites": "Favorites",
@@ -59,6 +59,7 @@
"dailyReminder": {
"title": "Daily Reminder",
"timesUnit": "times",
"timesUnitSingular": "time",
"pushLabel": "Push Reminder",
"ok": "Ok",
"minus": "Decrease",
@@ -79,7 +80,7 @@
"language": "Language",
"version": "Version",
"widgetTitle": "iOS Widget",
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Dear Mama” → add a size you like."
},
"consent": {
"title": "You Are Perfect.",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}",
"next": "Siguiente",
"skip": "Saltar",
"skipAll": "Saltar introducción",
"skipAll": "Saltar",
"q1Title": "¿Cómo te sientes últimamente?",
"q1Desc": "No hay respuestas correctas. Puedes saltar y ajustar después.",
"q2Title": "¿Qué tipo de apoyo quieres?",
@@ -30,7 +30,7 @@
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
},
"home": {
"title": "Mindfulness",
"title": "Dear Mama",
"like": "Me gusta",
"dislike": "No me gusta",
"favorites": "Favoritos",
@@ -57,6 +57,7 @@
"dailyReminder": {
"title": "Recordatorio diario",
"timesUnit": "veces",
"timesUnitSingular": "vez",
"pushLabel": "Recordatorio Push",
"ok": "Ok",
"minus": "Disminuir",
@@ -77,7 +78,7 @@
"language": "Idioma",
"version": "Versión",
"widgetTitle": "Widget de iOS",
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Mindfulness” → añade el tamaño."
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Dear Mama” → añade el tamaño."
},
"consent": {
"agree": "Aceptar y Continuar",

View File

@@ -9,7 +9,7 @@
"progress": "{{current}}/{{total}}",
"next": "Próximo",
"skip": "Pular",
"skipAll": "Pular introdução",
"skipAll": "Pular",
"q1Title": "Como você tem se sentido ultimamente?",
"q1Desc": "Não há certo ou errado. Você pode pular e ajustar depois.",
"q2Title": "Que tipo de apoio você quer?",
@@ -30,7 +30,7 @@
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
},
"home": {
"title": "Mindfulness",
"title": "Dear Mama",
"like": "Curtir",
"dislike": "Não curtir",
"favorites": "Favoritos",
@@ -57,6 +57,7 @@
"dailyReminder": {
"title": "Lembrete diário",
"timesUnit": "vezes",
"timesUnitSingular": "vez",
"pushLabel": "Lembrete Push",
"ok": "Ok",
"minus": "Diminuir",
@@ -77,7 +78,7 @@
"language": "Idioma",
"version": "Versão",
"widgetTitle": "Widget do iOS",
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Mindfulness” → adicione o tamanho."
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Dear Mama” → adicione o tamanho."
},
"consent": {
"agree": "Concordar e Continuar",

View File

@@ -12,7 +12,7 @@
"progress": "{{current}}/{{total}}",
"next": "下一步",
"skip": "跳过",
"skipAll": "跳过整个引导",
"skipAll": "跳过",
"q1Title": "你最近的感受更接近哪一种?",
"q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。",
"q2Title": "你更希望获得哪种支持?",
@@ -33,7 +33,7 @@
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token建议用真机测试。"
},
"home": {
"title": "正念",
"title": "Dear Mama",
"like": "点赞",
"dislike": "讨厌",
"favorites": "收藏",
@@ -60,6 +60,7 @@
"dailyReminder": {
"title": "每日提醒",
"timesUnit": "次",
"timesUnitSingular": "次",
"pushLabel": "推送提醒",
"ok": "确定",
"minus": "减少次数",
@@ -80,7 +81,7 @@
"language": "语言",
"version": "版本",
"widgetTitle": "iOS 小组件",
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“正念” → 添加你喜欢的尺寸。"
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“Dear Mama” → 添加你喜欢的尺寸。"
},
"consent": {
"title": "你本就完美。",

View File

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

View File

@@ -5,6 +5,7 @@ import { fetchRecoWidget } from '@/src/services/recoApi';
import i18n from 'i18next';
import { getUserProfileScoring } from '@/src/storage/appStorage';
import { getLocalDayKey } from '@/src/utils/date';
import { wrapText } from '@/src/features/textWrap';
import {
appGroupGetString,
@@ -40,6 +41,12 @@ export type WidgetDailyRecoV1 = {
item: null | {
content_id: number;
text: string;
/**
* 预换行文案(由 App 侧使用 Text Wrap 算法生成,写入 App Group供 Widget 直接渲染)。
* - key 以 WidgetFamily 归一化small/medium/large
* - 值使用 `\n` 分行Widget SwiftUI 的 Text 会按换行符显示
*/
wrapped_text_by_family?: Partial<Record<'small' | 'medium' | 'large', string>>;
final_score?: number;
fallback_level_final?: number;
};
@@ -56,6 +63,58 @@ function safeJsonParse<T>(raw: string | null): T | null {
}
}
type WidgetFamilyKey = 'small' | 'medium' | 'large';
function widgetPreset(family: WidgetFamilyKey) {
// 与 iOS Widget`EmotionWidget.swift`)保持一致的显示口径(字体/行数/内边距)
// 注意:这里的 widthPt 是“常见 iPhone widget 尺寸”的近似值,用于把 pt 宽度换算为“可容纳 token 数”
// 真实渲染仍由系统决定,但这个近似能让算法在不同 family 下更稳定地产出更好看的换行。
switch (family) {
case 'small':
return { widthPt: 155, padding: 14, fontSize: 16, maxLines: 5 };
case 'medium':
return { widthPt: 329, padding: 16, fontSize: 18, maxLines: 6 };
case 'large':
return { widthPt: 329, padding: 18, fontSize: 22, maxLines: 8 };
}
}
function approxCapacityFromPt(args: { lang: 'EN' | 'TC'; usablePt: number; fontSize: number }): number {
// 与 wrapText(APP 近似降级) 的换算口径一致:把像素/pt 宽度近似换成“可容纳 token 数”
const usable = Math.max(0, args.usablePt);
const fs = Math.max(1, Math.round(args.fontSize));
const denom = args.lang === 'EN' ? Math.max(1, Math.round(fs * 0.55)) : Math.max(1, Math.round(fs * 0.95));
return Math.max(1, Math.floor(usable / denom));
}
async function buildWrappedTextByFamily(args: { text: string; lang: 'EN' | 'TC' }): Promise<Record<WidgetFamilyKey, string>> {
const families: WidgetFamilyKey[] = ['small', 'medium', 'large'];
const out: Partial<Record<WidgetFamilyKey, string>> = {};
for (const f of families) {
const p = widgetPreset(f);
const usablePt = Math.max(0, p.widthPt - p.padding * 2);
const capacity = approxCapacityFromPt({ lang: args.lang, usablePt, fontSize: p.fontSize });
const res = await wrapText({
text: args.text,
lang: args.lang,
context: 'WIDGET',
availableWidth: capacity,
maxLines: p.maxLines,
overflowMode: 'ELLIPSIS',
lineMode: 'AUTO',
configVersion: 'v1-widget',
debug: false,
// Widget 侧不依赖真实测量:默认 APPROX 即可(确保可在 Extension 独立工作)
});
out[f] = res.wrappedText;
}
return out as Record<WidgetFamilyKey, string>;
}
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
return {
profile_version: scoringProfile.profile_version,
@@ -126,7 +185,29 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
const today = getLocalDayKey(new Date());
const cached = await getWidgetDailyRecoCache();
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
// 若今日已有缓存,但缺少预换行字段:补齐后触发 reload不必请求后端
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) {
const hasWrapped = Boolean(cached.item.wrapped_text_by_family && Object.keys(cached.item.wrapped_text_by_family).length > 0);
if (hasWrapped) return;
const wrapLang: 'EN' | 'TC' = cached.lang === 'en' ? 'EN' : 'TC';
try {
const wrapped = await buildWrappedTextByFamily({ text: cached.item.text, lang: wrapLang });
await setWidgetDailyRecoCache({
...cached,
saved_at: new Date().toISOString(),
item: { ...cached.item, wrapped_text_by_family: wrapped },
source: cached.source ?? 'app',
});
await appGroupReloadAllTimelines();
} catch (e) {
// 预换行失败不阻塞Widget 仍可用原文 + 系统换行
if (typeof __DEV__ !== 'undefined' && __DEV__) {
console.log('[DailyWidgetReco] 预换行补齐失败:', args?.reason ?? 'unknown', e);
}
}
return;
}
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
if (!scoringProfile) return;
@@ -146,6 +227,8 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
if (!top?.text) return;
const lang = toBackendLocaleFromLanguageTag(i18n.language);
const wrapLang: 'EN' | 'TC' = lang === 'en' ? 'EN' : 'TC';
const wrapped = await buildWrappedTextByFamily({ text: top.text, lang: wrapLang });
await setWidgetDailyRecoCache({
schema_version: 1,
saved_at: new Date().toISOString(),
@@ -155,6 +238,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
item: {
content_id: top.content_id,
text: top.text,
wrapped_text_by_family: wrapped,
final_score: top.final_score,
fallback_level_final: top.fallback_level_final,
},

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../storage/appStorage', () => ({
getLastHandledNotificationId: vi.fn(async () => null),
setLastHandledNotificationId: vi.fn(async () => undefined),
setPendingHomePushMessage: vi.fn(async () => undefined),
}));
import { buildPendingHomePushMessageFromResponse } from '../pushNotificationRoute';
describe('pushNotificationRoute.buildPendingHomePushMessageFromResponse', () => {
it('能从每日推荐 push payload 提取 home 文案', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-1',
content: {
title: '每日推荐',
body: '先用通知正文兜底',
data: {
scene: 'push',
target_screen: 'home',
home_text: '点击推送后回到首页展示这句文案',
content_id: '42',
},
},
},
},
});
expect(result).toMatchObject({
notification_id: 'notif-1',
title: '每日推荐',
text: '点击推送后回到首页展示这句文案',
content_id: 42,
scene: 'push',
});
});
it('home_text 缺失时回退到通知正文', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-2',
content: {
body: '直接展示通知正文',
data: {
target_screen: 'home',
},
},
},
},
});
expect(result?.text).toBe('直接展示通知正文');
expect(result?.notification_id).toBe('notif-2');
});
it('非 home 目标且非 push 场景时忽略', () => {
const result = buildPendingHomePushMessageFromResponse({
notification: {
request: {
identifier: 'notif-3',
content: {
body: '这条不应该进入首页',
data: {
target_screen: 'profile',
scene: 'other',
},
},
},
},
});
expect(result).toBeNull();
});
});

View File

@@ -5,7 +5,13 @@ import { Platform } from 'react-native';
import { httpJson } from '../utils/http';
import { APP_ENV } from '../constants/env';
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
import {
getDailyReminderSettings,
getLastRegisteredPushPayload,
getOrCreateClientUserId,
getUserProfileScoring,
setLastRegisteredPushPayload,
} from '../storage/appStorage';
import type { UserProfileScoring } from '../storage/appStorage';
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
@@ -154,6 +160,40 @@ export async function registerPushToken(args: { pushToken: string }): Promise<vo
});
}
function isSystemNotificationPermissionGranted(status: unknown): boolean {
// iOS 可能出现 provisional临时授权在 Push 场景也应视为“可获取 token 并上报”
return status === 'granted' || status === 'provisional';
}
export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> {
// 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认)
const settings = await Notifications.getPermissionsAsync();
if (!isSystemNotificationPermissionGranted(settings.status)) return { ok: false, reason: 'permission_not_granted' };
const clientUserId = await getOrCreateClientUserId();
const token = await getExpoPushTokenOrThrow();
const env = toPushEnv(APP_ENV);
const appId = pickAppId();
// 去重规则(更严格):
// - 只有当 token + client_user_id + env + app_id 全都一致时才跳过
// - 避免出现“client_user_id 变化但 token 没变,导致后端绑定不更新”的问题
const last = await getLastRegisteredPushPayload().catch(() => null);
if (
last &&
last.pushToken === token &&
last.clientUserId === clientUserId &&
last.env === env &&
last.appId === appId
) {
return { ok: true, reason: 'already_registered' };
}
await registerPushToken({ pushToken: token });
await setLastRegisteredPushPayload({ pushToken: token, clientUserId, env, appId });
return { ok: true, reason: 'registered' };
}
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
const clientUserId = await getOrCreateClientUserId();
const tz = pickTimezone();

View File

@@ -0,0 +1,100 @@
import {
getLastHandledNotificationId,
setLastHandledNotificationId,
setPendingHomePushMessage,
type PendingHomePushMessage,
} from '../storage/appStorage';
type NotificationContentLike = {
title?: string | null;
body?: string | null;
data?: Record<string, unknown> | null;
};
export type NotificationResponseLike = {
notification?: {
request?: {
identifier?: string;
content?: NotificationContentLike;
};
};
};
type HomePushListener = (message: PendingHomePushMessage) => void;
const homePushListeners = new Set<HomePushListener>();
function readString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readContentId(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return Math.trunc(parsed);
}
return undefined;
}
function notifyHomePushListeners(message: PendingHomePushMessage): void {
for (const listener of homePushListeners) {
listener(message);
}
}
export function subscribeHomePushMessage(listener: HomePushListener): () => void {
homePushListeners.add(listener);
return () => {
homePushListeners.delete(listener);
};
}
export function buildPendingHomePushMessageFromResponse(
response: NotificationResponseLike
): PendingHomePushMessage | null {
const request = response.notification?.request;
const content = request?.content;
const data =
content?.data && typeof content.data === 'object' && !Array.isArray(content.data)
? content.data
: {};
const targetScreen = readString(data.target_screen);
const scene = readString(data.scene);
const shouldOpenHome = targetScreen === 'home' || scene === 'push';
if (!shouldOpenHome) return null;
const text = readString(data.home_text) ?? readString(content?.body);
if (!text) return null;
return {
notification_id: readString(request?.identifier) ?? `push-${Date.now()}`,
received_at: new Date().toISOString(),
text,
title: readString(content?.title),
content_id: readContentId(data.content_id),
scene,
};
}
export async function persistHomePushMessageFromResponse(
response: NotificationResponseLike
): Promise<PendingHomePushMessage | null> {
const message = buildPendingHomePushMessageFromResponse(response);
if (!message) return null;
const lastHandledNotificationId = await getLastHandledNotificationId();
if (lastHandledNotificationId === message.notification_id) {
return null;
}
await setPendingHomePushMessage(message);
await setLastHandledNotificationId(message.notification_id);
notifyHomePushListeners(message);
return message;
}

View File

@@ -18,6 +18,11 @@ const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
const KEY_UI_THEME_MODE = 'ui.theme.mode';
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token保留兼容读取
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新token+client_user_id+env+app_id
const KEY_PUSH_PENDING_HOME_MESSAGE = 'push.pendingHomeMessage';
const KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID = 'push.lastHandledNotificationId';
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
export type Reaction = 'like' | 'dislike';
@@ -69,6 +74,92 @@ export type DailyReminderSettings = {
pushEnabled: boolean;
};
export async function getLastRegisteredPushToken(): Promise<string | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_TOKEN);
return raw ? String(raw) : null;
}
export async function setLastRegisteredPushToken(token: string): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_TOKEN, String(token));
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_AT, new Date().toISOString());
}
export type LastRegisteredPushPayload = {
pushToken: string;
clientUserId: string;
env: 'dev' | 'prod';
appId: string;
savedAt: string; // ISO8601
};
export async function getLastRegisteredPushPayload(): Promise<LastRegisteredPushPayload | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD);
if (!raw) return null;
try {
const obj = JSON.parse(raw) as Partial<LastRegisteredPushPayload>;
if (!obj || typeof obj !== 'object') return null;
if (!obj.pushToken || !obj.clientUserId || !obj.env || !obj.appId || !obj.savedAt) return null;
if (obj.env !== 'dev' && obj.env !== 'prod') return null;
return obj as LastRegisteredPushPayload;
} catch {
return null;
}
}
export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredPushPayload, 'savedAt'>): Promise<void> {
const savedAt = new Date().toISOString();
const full: LastRegisteredPushPayload = { ...payload, savedAt };
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD, JSON.stringify(full));
// 同时写入旧 key便于兼容老逻辑/快速排查
await setLastRegisteredPushToken(payload.pushToken);
}
export type PendingHomePushMessage = {
notification_id: string;
received_at: string; // ISO8601
text: string;
title?: string;
content_id?: number;
scene?: string;
};
export async function getPendingHomePushMessage(): Promise<PendingHomePushMessage | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_PENDING_HOME_MESSAGE);
if (!raw) return null;
try {
const obj = JSON.parse(raw) as Partial<PendingHomePushMessage>;
if (!obj || typeof obj !== 'object') return null;
if (!obj.notification_id || !obj.received_at || !obj.text) return null;
return {
notification_id: String(obj.notification_id),
received_at: String(obj.received_at),
text: String(obj.text),
title: obj.title ? String(obj.title) : undefined,
content_id: Number.isFinite(obj.content_id) ? Number(obj.content_id) : undefined,
scene: obj.scene ? String(obj.scene) : undefined,
};
} catch {
return null;
}
}
export async function setPendingHomePushMessage(message: PendingHomePushMessage): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_PENDING_HOME_MESSAGE, JSON.stringify(message));
}
export async function clearPendingHomePushMessage(): Promise<void> {
await AsyncStorage.removeItem(KEY_PUSH_PENDING_HOME_MESSAGE);
}
export async function getLastHandledNotificationId(): Promise<string | null> {
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID);
return raw ? String(raw) : null;
}
export async function setLastHandledNotificationId(notificationId: string): Promise<void> {
await AsyncStorage.setItem(KEY_PUSH_LAST_HANDLED_NOTIFICATION_ID, String(notificationId));
}
export type RecoFeedCacheItem = {
content_id: number;
text: string;
@@ -188,7 +279,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
}
export type FavoriteItem = {
favId: string; // 唯一标识,支持重复点赞同一文案
favId: string; // 唯一标识
id: string;
/**
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
@@ -200,13 +291,28 @@ export type FavoriteItem = {
};
export async function getFavorites(): Promise<FavoriteItem[]> {
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
const raw = await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
// 去重:同一条文案只保留最新的一条,防止重复点击喜欢导致弹窗重复展示
const seen = new Set<string>();
const deduped: FavoriteItem[] = [];
for (const item of raw) {
const key = String(item.id);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
}
// 若发现历史数据有重复,顺便写回清理后的版本
if (deduped.length !== raw.length) {
await setJson(KEY_FAVORITES_ITEMS, deduped);
}
return deduped;
}
export async function addFavorite(item: FavoriteItem): Promise<void> {
const list = await getFavorites();
// 允许重复点赞,不再根据 id 去重
const newList = [item, ...list];
// 幂等写入:同一条文案只保留一条(最新),避免重复点击喜欢产生重复项
const filtered = list.filter(x => String(x.id) !== String(item.id));
const newList = [item, ...filtered];
console.log('Adding to favorites:', JSON.stringify(item));
await setJson(KEY_FAVORITES_ITEMS, newList);
}

View File

@@ -0,0 +1,9 @@
import { Platform } from 'react-native';
export function isIPadLike(width: number, height: number): boolean {
return Platform.OS === 'ios' && Math.min(width, height) >= 768;
}
export function clampContentWidth(width: number, maxWidth: number, horizontalPadding: number): number {
return Math.min(maxWidth, Math.max(0, width - horizontalPadding * 2));
}

View File

@@ -0,0 +1,31 @@
"""add push_send_log payload snapshot
Revision ID: 0003_add_push_send_log_payload
Revises: 0002_init_push_tables
Create Date: 2026-02-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0003_add_push_send_log_payload"
down_revision = "0002_init_push_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("push_send_log", sa.Column("content_id", sa.Integer(), nullable=True, comment="推送文案内容 ID可选"))
op.add_column("push_send_log", sa.Column("title", sa.String(length=128), nullable=True, comment="推送标题(可选)"))
op.add_column("push_send_log", sa.Column("body", sa.Text(), nullable=True, comment="推送正文(可选)"))
def downgrade() -> None:
op.drop_column("push_send_log", "body")
op.drop_column("push_send_log", "title")
op.drop_column("push_send_log", "content_id")

View File

@@ -7,16 +7,70 @@ from fastapi.responses import HTMLResponse
from pydantic import BaseModel, HttpUrl
from app.core.config import get_settings
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
from app.legal_docs import (
PRIVACY_POLICY_MD,
TERMS_OF_USE_MD,
choose_content_by_lang,
render_as_simple_html,
split_bilingual_markdown,
)
router = APIRouter(prefix="/v1/legal", tags=["legal"])
INTERNAL_SENTINEL = "__internal__"
# 技术支持页文案EN / TC
SUPPORT_MD = """Dear Mama | Technical Support
Last updated: February 2026
If you need technical support for Dear Mama, please contact us:
- Support email: leitinglan@project-c.org
Support scope (including but not limited to):
- App installation or update issues
- App crashes, freezes, or abnormal behavior
- Notification or widget related issues
- Difficulty accessing privacy policy or terms pages
To help us process your request faster, please include:
- Device model and OS version
- App version
- A brief issue description and occurrence time
- Screenshots or screen recording (if available)
Service commitment:
- We generally respond within 3-5 business days
- Emergency availability may vary by holidays and local time
---
Dear Mama技術支援
最後更新日期2026 年 2 月
如需 Dear Mama 的技術支援,請透過以下方式聯絡我們:
- 支援信箱leitinglan@project-c.org
支援範圍(包含但不限於):
- App 安裝或更新問題
- App 閃退、卡頓或異常行為
- 通知或小組件相關問題
- 隱私政策或使用條款頁面無法開啟
為了加快處理,建議提供:
- 裝置型號與系統版本
- App 版本號
- 問題描述與發生時間
- 截圖或螢幕錄影(如有)
服務承諾:
- 一般會在 3-5 個工作日內回覆
- 節假日或時區差異時,回覆時間可能延長
"""
# 当前多语言仅支持 EN / TC繁体
ResolvedLang = Literal["en", "tc"]
BILINGUAL_CONTENT_LANGUAGE = "en, zh-Hant"
class LegalLinksResponse(BaseModel):
@@ -36,13 +90,23 @@ def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
if not accept_language:
return "en"
s = accept_language.lower()
# 目前只支持 EN / TC只要是中文或显式 tc都归到 tc
if "tc" in s:
return "tc"
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
return "tc"
# 按语言优先级顺序逐项解析,避免“只要包含 zh 就全判 tc”。
# 例如en-US,en;q=0.9,zh-CN;q=0.8 应命中 en而不是 tc。
items = [seg.strip().lower() for seg in accept_language.split(",") if seg.strip()]
for item in items:
lang_tag = item.split(";", 1)[0].strip()
if not lang_tag:
continue
if lang_tag == "tc":
return "tc"
if lang_tag.startswith(("zh-hant", "zh-tw", "zh-hk", "zh-mo")):
return "tc"
if lang_tag.startswith("zh"):
return "tc"
if lang_tag.startswith("en"):
return "en"
return "en"
@@ -85,6 +149,18 @@ def _normalize_internal_url(request: Request, url: str, internal_path: str) -> s
return _join_base_url(str(request.base_url), internal_path)
def _build_bilingual_content(text: str) -> str:
"""
固定输出双语协议内容EN 在前TC 在后。
若缺少 TC则仅返回 EN。
"""
en, tc = split_bilingual_markdown(text)
if not tc:
return en
return f"{en}\n\n---\n{tc}"
@router.get("/links", response_model=LegalLinksResponse)
async def get_legal_links(request: Request) -> LegalLinksResponse:
"""
@@ -109,11 +185,18 @@ async def get_privacy_policy(request: Request) -> HTMLResponse:
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
title = "Hey Mama | Privacy Policy" if resolved == "en" else "Hey Mama隱私權政策"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama隱私權政策"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(PRIVACY_POLICY_MD)
title = "Dear Mama | Privacy Policy / 隱私權政策"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
@router.get("/terms", response_class=HTMLResponse)
@@ -123,9 +206,37 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
"""
accept_language = request.headers.get("accept-language")
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
title = "Hey Mama Terms of Use" if resolved == "en" else "Hey Mama 使用條款"
page = render_as_simple_html(title=title, content=content)
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
title = "Dear Mama Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(TERMS_OF_USE_MD)
title = "Dear Mama Terms of Use / 使用條款"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})
@router.get("/support", response_class=HTMLResponse)
async def get_support_page(request: Request) -> HTMLResponse:
"""
技术支持页面(用于 App 审核的可访问 URL
"""
accept_language = request.headers.get("accept-language")
if accept_language:
lang = _resolve_lang(accept_language)
content, resolved = choose_content_by_lang(SUPPORT_MD, lang)
title = "Dear Mama | Technical Support" if resolved == "en" else "Dear Mama技術支援"
html_lang = "en" if resolved == "en" else "zh-Hant"
page = render_as_simple_html(title=title, content=content, html_lang=html_lang)
return HTMLResponse(content=page, headers={"Content-Language": html_lang})
content = _build_bilingual_content(SUPPORT_MD)
title = "Dear Mama | Technical Support / 技術支援"
page = render_as_simple_html(title=title, content=content, html_lang="en")
return HTMLResponse(content=page, headers={"Content-Language": BILINGUAL_CONTENT_LANGUAGE})

View File

@@ -7,7 +7,7 @@ import httpx
import redis
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.limits import rate_limit_push_by_ip
@@ -16,6 +16,7 @@ from app.db.models.push_preference import PushPreference
from app.db.models.push_token import PushToken
from app.db.models.push_send_log import PushSendLog
from app.db.session import get_db
from app.features.push_payload import build_home_push_data
from app.features.user_profile_scoring.types import UserProfileV1_2
from app.worker import celery_app
@@ -153,6 +154,32 @@ async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db))
token.is_active = True
token.last_seen_at = _ensure_utc(now)
# 额外:尽早写入/补齐时区与语言(用于按用户时区生成排程)
# 说明:
# - 用户首次授权后会立即调用 /register但不一定马上进入“每日提醒”确认页
# - 若 push_preferences 里 timezone 为空,会导致排程回退到 UTC体验不符合预期
if req.device_meta:
tz = (req.device_meta.timezone or "").strip() or None
loc = (req.device_meta.locale or "").strip() or None
if tz or loc:
qpref = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
rpref = await db.execute(qpref)
pref = rpref.scalar_one_or_none()
if pref is None:
pref = PushPreference(
client_user_id=req.client_user_id,
enabled=False,
times_per_day=0,
timezone=tz,
locale=loc,
)
db.add(pref)
else:
if tz and not (pref.timezone or "").strip():
pref.timezone = tz
if loc and not (pref.locale or "").strip():
pref.locale = loc
await db.commit()
return {"status": "ok"}
@@ -187,16 +214,20 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
else:
pref.enabled = enabled
pref.times_per_day = times
pref.timezone = req.timezone
pref.locale = req.locale
# 注意:只在客户端显式传入时覆盖,避免把已保存的 timezone/locale 清空导致排程回退到 UTC
if req.timezone is not None:
pref.timezone = req.timezone
if req.locale is not None:
pref.locale = req.locale
if req.user_profile is not None:
pref.user_profile_json = req.user_profile.model_dump(mode="json")
await db.commit()
# 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate这里用 now 兜底)
updated_at = getattr(pref, "updated_at", None)
updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None
# 返回更新时间
# - 某些运行环境/驱动组合下commit 后访问 ORM 字段可能触发隐式 IO导致 async 下报 MissingGreenlet。
# - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。
updated_at_iso = datetime.now(timezone.utc).isoformat()
return PushPreferencesResponse(
client_user_id=req.client_user_id,
@@ -256,10 +287,19 @@ async def test_push(
_ = UserProfileV1_2.model_validate(pref.user_profile_json)
# V1先发固定测试文案后续在定时任务中替换为推荐模块的 push 场景模板
title = req.title or "Hey Mama"
title = req.title or "Dear Mama"
body = req.body or "这是一条测试推送dev"
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
expo_res = await _send_expo_push(
to=token.push_token,
title=title,
body=body,
data=build_home_push_data(
client_user_id=req.client_user_id,
body=body,
scene="push",
),
)
_ = accept_language
return {"status": "ok", "expo": expo_res}
@@ -296,6 +336,7 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]
"worker": {"ok": False, "worker_count": 0},
"beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None},
"db": {"ok": False, "push_send_log_latest_created_at": None},
"db_push_tokens": {"ok": False, "count": None, "latest": None},
"now_utc": datetime.now(timezone.utc).isoformat(),
}
@@ -342,5 +383,36 @@ async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]
except Exception as e:
out["db"]["error"] = f"{type(e).__name__}: {e}"
# 4) DB查询 push_tokens 计数与最近一条(用于确认 /v1/push/register 是否真正落库)
try:
qcount = select(func.count()).select_from(PushToken)
rcount = await db.execute(qcount)
cnt = int(rcount.scalar_one() or 0)
qlatest = select(PushToken).order_by(PushToken.last_seen_at.desc()).limit(1)
rlatest = await db.execute(qlatest)
t = rlatest.scalar_one_or_none()
latest_obj = None
if t is not None:
tok = str(t.push_token or "")
masked = tok[:10] + "***" + tok[-6:] if len(tok) > 20 else (tok[:6] + "***" if tok else "")
latest_obj = {
"id": int(getattr(t, "id", 0) or 0),
"client_user_id": str(t.client_user_id),
"env": str(t.env),
"app_id": str(t.app_id),
"platform": str(t.platform),
"is_active": bool(t.is_active),
"last_seen_at": t.last_seen_at.isoformat() if t.last_seen_at else None,
"push_token_masked": masked,
}
out["db_push_tokens"]["ok"] = True
out["db_push_tokens"]["count"] = cnt
out["db_push_tokens"]["latest"] = latest_obj
except Exception as e:
out["db_push_tokens"]["error"] = f"{type(e).__name__}: {e}"
return out

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy import Date, DateTime, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -33,5 +33,10 @@ class PushSendLog(Base):
status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed")
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)")
# 发送内容快照(用于观测 + 去重)
content_id: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="推送文案内容 ID可选")
title: Mapped[str | None] = mapped_column(String(length=128), nullable=True, comment="推送标题(可选)")
body: Mapped[str | None] = mapped_column(Text, nullable=True, comment="推送正文(可选)")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any, Optional
def build_home_push_data(
*,
client_user_id: str,
body: str,
scene: str = "push",
content_id: Optional[int] = None,
) -> dict[str, Any]:
"""
构建客户端点击通知后回到 Home 所需的最小 payload。
"""
data: dict[str, Any] = {
"client_user_id": str(client_user_id),
"scene": str(scene),
"target_screen": "home",
"deep_link": "client://home",
"home_text": str(body),
}
if content_id is not None:
data["content_id"] = int(content_id)
return data

View File

@@ -11,21 +11,21 @@ ResolvedLang = Literal["en", "tc"]
# 协议原文(直接来自仓库中的 Markdown 文档)。
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
PRIVACY_POLICY_MD = """Hey Mama | Privacy Policy
PRIVACY_POLICY_MD = """Dear Mama | Privacy Policy
Last updated: February 2026
1. Introduction
Welcome to Hey Mama (“the App,” “we,” “us”).
Welcome to Dear Mama (“the App,” “we,” “us”).
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
2. Data Controller and Scope
The App is operated and maintained by the Hey Mama team.
The App is operated and maintained by the Dear Mama team.
This Privacy Policy applies to information processing activities related to your use of the App.
3. Information We Collect
3.1 Information You Provide
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
Dear Mama does not require account registration and does not require you to provide personally identifiable information.
During your use of the App, you may optionally provide or generate the following information:
- Reminder settings (e.g., reminder frequency)
- Text content you view, save as favorites, or create within the App (if available)
@@ -59,7 +59,7 @@ We do not:
- Use your data for third-party advertising purposes
7. Third-Party Services
Hey Mama currently does not integrate third-party advertising or marketing services.
Dear Mama currently does not integrate third-party advertising or marketing services.
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
@@ -67,7 +67,7 @@ If we later integrate third-party analytics or technical services, we will updat
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
9. Minors
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
Dear Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
10. Changes to This Privacy Policy
@@ -75,17 +75,17 @@ We may update this Privacy Policy from time to time. The updated version will be
---
Hey Mama隱私權政策
Dear Mama隱私權政策
最後更新日期2026 年 2 月
一、前言
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
歡迎使用 Dear Mama以下簡稱「本 App」、「我們」
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Dear Mama 時,我們如何收集、使用、保存與保護相關資訊。
當您下載、存取或使用本 App即表示您已閱讀、理解並同意本隱私權政策之內容。
二、我們收集的資訊
1. 使用者主動提供的資訊
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
Dear Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
在使用過程中,您可能會選擇性提供或產生以下資訊:
- 提醒設定(例如提醒頻率)
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
@@ -99,7 +99,7 @@ Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
三、推送通知
在取得您同意後,Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
在取得您同意後,Dear Mama 可能會向您發送提醒推送,例如每日肯定語提示。
- 推送內容僅包含一般文字資訊
- 不包含任何敏感個人資料
- 您可隨時於裝置系統設定中關閉通知功能
@@ -117,14 +117,14 @@ Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身
- 將資料用於第三方廣告投放
六、第三方服務
目前 Hey Mama 未整合第三方廣告或行銷服務。
目前 Dear Mama 未整合第三方廣告或行銷服務。
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
七、資料保存與安全
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
八、未成年人說明
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
Dear Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
若您為未成年人,請在監護人同意與陪同下使用本 App。
九、隱私權政策的變更
@@ -132,17 +132,17 @@ Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App即視為同意更新內容。
"""
TERMS_OF_USE_MD = """Hey Mama Terms of Use
TERMS_OF_USE_MD = """Dear Mama Terms of Use
Last updated: February 2026
Welcome to Hey Mama (“the App,” “we,” or “us”).
Welcome to Dear Mama (“the App,” “we,” or “us”).
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
1. Intended Audience
Hey Mama is intended for adults only.
Dear Mama is intended for adults only.
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
2. Services Provided
Hey Mama provides text-based content and features, including but not limited to:
Dear Mama provides text-based content and features, including but not limited to:
- Daily affirmations and mindfulness text
- User-configured reminders and push notifications
- Home screen widgets displaying affirmation text
@@ -184,17 +184,17 @@ Updated versions will be made available within the App or related pages. Continu
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
---
Hey Mama 使用條款
Dear Mama 使用條款
最後更新日期2026 年 2 月
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
歡迎使用 Dear Mama以下簡稱「本 App」、「我們」
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App即表示您已閱讀、理解並同意遵守本條款。
1. 服務對象與使用資格
Hey Mama 僅供成年人使用intended for adults
Dear Mama 僅供成年人使用intended for adults
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
2. 服務內容
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
Dear Mama 提供以文字形式為主的內容與功能,包括但不限於:
- 每日肯定語與正念文字內容
- 使用者設定的提醒與推送通知
- 桌面小組件顯示肯定語文字
@@ -265,7 +265,7 @@ def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, Resolved
return en, "en"
def render_as_simple_html(title: str, content: str) -> str:
def render_as_simple_html(title: str, content: str, html_lang: str = "en") -> str:
"""
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
@@ -273,8 +273,9 @@ def render_as_simple_html(title: str, content: str) -> str:
safe_title = html.escape(title)
safe_content = html.escape(content)
safe_lang = html.escape(html_lang or "en")
return f"""<!doctype html>
<html lang="en">
<html lang="{safe_lang}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -9,13 +9,14 @@ from zoneinfo import ZoneInfo
import httpx
from celery import current_app, shared_task
from sqlalchemy import select
from sqlalchemy import select, update
from app.core.config import get_settings
from app.db.models.push_preference import PushPreference
from app.db.models.push_send_log import PushSendLog
from app.db.models.push_token import PushToken
from app.db.session import AsyncSessionLocal
from app.features.push_payload import build_home_push_data
from app.features.personalized_reco.content_repository.types import normalize_locale
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
@@ -44,7 +45,8 @@ def _pick_reco_locale(pref_locale: Optional[str]) -> str:
def _pick_title(locale: str) -> str:
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
# 需求tc 语言使用繁体标题
return "每日推薦" if str(locale) == "tc" else "Daily Reminder"
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
@@ -67,7 +69,11 @@ async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
"""
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)
将窗口均匀切分为 n 个区间,并在每段内取“中点 + 受限抖动”的时间点
目的:
- 尽量均匀分布(避免相邻两条推送随机到非常接近的时间)
- 仍保留一定随机性,避免过于机械
"""
if n <= 0:
@@ -84,8 +90,15 @@ def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[dat
if seg <= 0:
out.append(seg_start)
continue
jitter = random.random() * seg
out.append(seg_start + timedelta(seconds=jitter))
# 受限抖动:在每段的 [25%, 75%] 区间内取点
# 这样相邻两段的最小间隔为 50% 段长,能显著减少“随机挤在一起”。
mid = seg_start + timedelta(seconds=seg * 0.5)
jitter = (random.random() - 0.5) * (seg * 0.5) # [-0.25*seg, +0.25*seg]
out.append(mid + timedelta(seconds=jitter))
# 保序(理论上天然有序,这里再保险)
out.sort()
return out
@@ -167,16 +180,25 @@ async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -
created += 1
# 投递 ETA 发送任务
current_app.send_task(
"tasks.push.send_scheduled",
kwargs={
"client_user_id": pref.client_user_id,
"local_date": target.local_date.isoformat(),
"slot_index": int(idx),
},
eta=dt_utc,
)
scheduled += 1
try:
current_app.send_task(
"tasks.push.send_scheduled",
kwargs={
"client_user_id": pref.client_user_id,
"local_date": target.local_date.isoformat(),
"slot_index": int(idx),
},
eta=dt_utc,
)
scheduled += 1
except Exception as e:
# 关键:如果投递失败(例如 broker 短暂不可用),不要让 log 永远卡在 scheduled
log.status = "failed"
log.error = f"enqueue_failed:{type(e).__name__}"
try:
await session.commit()
except Exception:
await session.rollback()
return {"created": created, "scheduled": scheduled}
@@ -209,6 +231,34 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
return {"status": "noop", "reason": "no_log"}
if str(log.status) == "sent":
return {"status": "noop", "reason": "already_sent"}
if str(log.status) not in ("scheduled", "sending"):
# 例如 failed/skipped不再重复尝试
return {"status": "noop", "reason": f"not_retryable:{log.status}"}
# 原子抢占:避免重复发送
# - scheduled正常抢占 scheduled -> sending
# - sending如果长时间卡在 sending进程崩溃/网络异常等),允许“超时接管”继续执行
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
steal_cutoff = now_utc_naive - timedelta(minutes=10)
res = await session.execute(
update(PushSendLog)
.where(
PushSendLog.id == log.id,
(
(PushSendLog.status == "scheduled")
| (
(PushSendLog.status == "sending")
& (PushSendLog.sent_at.is_(None))
& (PushSendLog.scheduled_at <= steal_cutoff)
)
),
)
.values(status="sending", error=None)
)
await session.commit()
if (res.rowcount or 0) <= 0:
return {"status": "noop", "reason": "already_in_progress_or_processed"}
log.status = "sending"
# 2) 当前偏好检查(用户可能中途关闭/改次数)
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
@@ -235,71 +285,128 @@ async def _send_once_async(*, client_user_id: str, local_date: date, slot_index:
await session.commit()
return {"status": "failed", "reason": "no_active_token"}
# 4) 生成文案(复用推荐模块 push 场景)
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
title = _pick_title(reco_locale)
if pref.user_profile_json:
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
else:
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
user_profile = UserProfileV1_2.model_validate(
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
)
# 直接复用 reco 的 Celery 任务实现(同步函数)
from app.tasks.reco import generate as reco_generate
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
body = ""
try:
items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list):
body = str(items[0].get("text") or "").strip()
except Exception:
# 4) 生成文案(复用推荐模块 push 场景)
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
title = _pick_title(reco_locale)
if pref.user_profile_json:
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
else:
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
user_profile = UserProfileV1_2.model_validate(
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
)
# 关键:这里不能调用 tasks.reco.generate内部会 asyncio.run否则会嵌套事件循环崩溃。
from app.tasks.reco import run_reco_payload_async
# 去重:用户推送过的内容尽量不再推送
# 说明:
# - 依赖 push_send_log.content_id需先完成对应 DB 迁移)
# - 为避免历史过长导致 already_recommended_ids 过大,这里取“最近若干条已推送内容”近似全量去重
used_ids: list[int] = []
try:
qused = (
select(PushSendLog.content_id)
.where(
PushSendLog.client_user_id == client_user_id,
PushSendLog.content_id.is_not(None),
PushSendLog.id != log.id,
)
# 优先排除最近发送过的内容
.order_by(PushSendLog.local_date.desc(), PushSendLog.slot_index.desc())
.limit(5000)
)
rused = await session.execute(qused)
used_ids = [int(x) for x in rused.scalars().all() if x is not None]
except Exception:
used_ids = []
body = ""
picked_content_id: int | None = None
try:
reco_payload = await run_reco_payload_async(
scene="push",
user_profile=user_profile,
k=3,
locale=reco_locale,
already_recommended_ids=used_ids,
)
items = (reco_payload or {}).get("items") or []
if items and isinstance(items, list):
for it in items:
if not isinstance(it, dict):
continue
cid = it.get("content_id")
txt = str(it.get("text") or "").strip()
if not txt:
continue
if cid is not None:
try:
cid_i = int(cid)
except Exception:
cid_i = None
else:
cid_i = None
if cid_i is not None and cid_i in used_ids:
continue
picked_content_id = cid_i
body = txt
break
except Exception:
body = ""
if not body:
body = "给自己一句温柔的话。"
if not body:
# tc 语言兜底文案使用繁体
body = "給自己一句溫柔的話。" if reco_locale == "tc" else "给自己一句温柔的话。"
# 5) 发送
try:
# 5) 发送
expo_res = await _send_expo_push(
to=str(token.push_token),
title=title,
body=body,
data={"client_user_id": client_user_id, "scene": "push"},
data=build_home_push_data(
client_user_id=client_user_id,
body=body,
scene="push",
content_id=picked_content_id,
),
)
# 6) 解析 Expo 回执,必要时停用 token
try:
data_list = (expo_res or {}).get("data") or []
if data_list and isinstance(data_list, list):
first = data_list[0] or {}
if first.get("status") == "error":
details = first.get("details") or {}
err = str(details.get("error") or first.get("message") or "expo_error")
log.status = "failed"
log.error = err
if "DeviceNotRegistered" in err:
token.is_active = False
await session.commit()
return {"status": "failed", "expo": expo_res}
except Exception:
# 忽略解析异常,继续按成功处理
pass
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
log.title = title
log.body = body
log.content_id = picked_content_id
await session.commit()
return {"status": "sent", "expo": expo_res}
except Exception as e:
# 兜底:任何未预期异常都不要让状态卡在 sending
log.status = "failed"
log.error = f"send_failed:{type(e).__name__}"
log.error = f"unexpected:{type(e).__name__}"
await session.commit()
return {"status": "failed", "error": str(e)}
# 6) 解析 Expo 回执,必要时停用 token
try:
data_list = (expo_res or {}).get("data") or []
if data_list and isinstance(data_list, list):
first = data_list[0] or {}
if first.get("status") == "error":
details = first.get("details") or {}
err = str(details.get("error") or first.get("message") or "expo_error")
log.status = "failed"
log.error = err
if "DeviceNotRegistered" in err:
token.is_active = False
await session.commit()
return {"status": "failed", "expo": expo_res}
except Exception:
# 忽略解析异常,继续按成功处理
pass
log.status = "sent"
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
log.error = None
await session.commit()
return {"status": "sent", "expo": expo_res}
@shared_task(name="tasks.push.send_scheduled")
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
@@ -310,3 +417,50 @@ def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) ->
d = date.fromisoformat(str(local_date))
return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index)))
@shared_task(name="tasks.push.requeue_overdue")
def requeue_overdue(*, grace_seconds: int = 300, limit: int = 200) -> dict[str, Any]:
"""
补偿任务:扫描“已到时间但仍处于 scheduled”的记录并重新投递发送任务。
目的:
- 覆盖 broker 短暂不可用、worker 重启、ETA 任务丢失等导致的“scheduled 卡住”
- 与 send_scheduled 内部的原子状态抢占配合,避免重复发送
"""
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
cutoff = now_utc_naive - timedelta(seconds=int(grace_seconds))
async def _run() -> dict[str, Any]:
requeued = 0
async with AsyncSessionLocal() as session:
q = (
select(PushSendLog)
.where(
PushSendLog.status.in_(("scheduled", "sending")),
PushSendLog.sent_at.is_(None),
PushSendLog.scheduled_at <= cutoff,
)
.order_by(PushSendLog.scheduled_at.asc())
.limit(int(limit))
)
rows = await session.execute(q)
logs = list(rows.scalars().all())
for log in logs:
try:
current_app.send_task(
"tasks.push.send_scheduled",
kwargs={
"client_user_id": str(log.client_user_id),
"local_date": str(log.local_date),
"slot_index": int(log.slot_index),
},
)
requeued += 1
except Exception:
# 忽略单条投递失败,交给下一轮补偿
continue
return {"status": "ok", "requeued": requeued, "cutoff": cutoff.isoformat()}
return asyncio.run(_run())

View File

@@ -53,6 +53,45 @@ async def _run_reco_async(
)
async def run_reco_payload_async(
*,
scene: Scene,
user_profile: UserProfileV1_2,
already_recommended_ids: Optional[list[Any]] = None,
touched_or_viewed_ids: Optional[list[Any]] = None,
k: Optional[int] = None,
now: Optional[datetime] = None,
locale: Optional[str] = None,
) -> dict[str, Any]:
"""
在“已有事件循环”内运行推荐并返回 payload。
用途:
- 供 Push 等 async 任务内部调用,避免 `asyncio.run()` 嵌套导致 RuntimeError
- 也便于未来在 API/任务间复用
"""
effective_now = _ensure_now(now)
effective_locale = _ensure_locale(locale)
# k 默认按场景(与 generate 保持一致)
if k is None:
k_i = 30 if scene == "feed" else 1
else:
k_i = int(k)
result = await _run_reco_async(
scene=scene,
user_profile=user_profile,
already_recommended_ids=list(already_recommended_ids or []),
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
k=int(k_i),
now=effective_now,
locale=effective_locale,
)
return result.model_dump()
def _run_reco_sync(
*,
scene: Scene,

View File

@@ -53,10 +53,19 @@ celery_app.conf.beat_schedule = {
},
"push-generate-daily-schedule": {
"task": "tasks.push.generate_daily_schedule",
"schedule": crontab(minute=10, hour=0),
# 由“每天一次”调整为“每 2 小时一次”UTC
"schedule": crontab(minute=10, hour="*/2"),
"kwargs": {"max_users": 5000},
"options": {"queue": f"{prefix}:celery"},
}
,
# 补偿:每 5 分钟扫描一次 overdue scheduled 并重投递
"push-requeue-overdue": {
"task": "tasks.push.requeue_overdue",
"schedule": crontab(minute="*/5"),
"kwargs": {"grace_seconds": 300, "limit": 200},
"options": {"queue": f"{prefix}:celery"},
},
}
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)

Binary file not shown.

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from app.features.push_payload import build_home_push_data
def test_build_home_push_data_contains_home_route_fields() -> None:
payload = build_home_push_data(
client_user_id="client-123",
body="今天也请温柔地对自己说话。",
scene="push",
content_id=9,
)
assert payload == {
"client_user_id": "client-123",
"scene": "push",
"target_screen": "home",
"deep_link": "client://home",
"home_text": "今天也请温柔地对自己说话。",
"content_id": 9,
}
def test_build_home_push_data_omits_content_id_when_missing() -> None:
payload = build_home_push_data(
client_user_id="client-123",
body="先看到这句,再回到首页。",
)
assert "content_id" not in payload
assert payload["target_screen"] == "home"
assert payload["home_text"] == "先看到这句,再回到首页。"

View File

@@ -0,0 +1,31 @@
# Splash Consent 補充說明
## i18n 開屏 consent 文案不生效(排查與修復記錄)
### 現象
- iOS 模擬器繁中開屏一直顯示舊文案(如「你很完美。」「一切 都會更好。」)
- 修改 zh-TW.json 的 consent.title/subtitle 後重跑 `npm run ios` 仍不生效
- 專案內搜尋「你很完美」找不到(舊文案來自快取)
### 排查結論
1. **舊文案來源**
- 專案內**沒有**「你很完美」「一切 都會更好」的完整句
- `zh-CN.json` 有近似句「你本就完美。」「一切都会变好。」,但 **App 的 i18n 未載入 zh-CN**,僅載入 `zh-TW`zh-TW.json`en`all.json 的 en 區塊)
- 結論:舊文案來自 **Metro / JS bundle 或 iOS 建置快取**(曾打包進去的舊 JSON
2. **語言與載入順序**`client/src/i18n/index.ts`
- `resources``zh-TW``zh-TW.json``en``all.json``en`
- **all.json 的 zh-TW 區塊不會被載入**,繁中唯一來源為 `zh-TW.json`
- 裝置語言經 `expo-localization.getLocales()[0].languageTag` 取得,經 `normalizeDeviceLanguageTagToAppLanguage` 對應到 `zh-TW``en`zh-Hant / zh-TW / zh-HK 等均對應 zh-TW
3. **修復與預防**
- 已加臨時 debug logi18n 初始化與開屏 consent 畫面會印出 `language``consent.title`(僅 __DEV__
- 已加 `clean:cache``start:clean``ios:clean` script若改 zh-TW 仍不生效,請執行清理後重啟或卸載 App 重裝
- 繁中 consent 文案**只改** `client/src/i18n/locales/zh-TW.json``consent.title``consent.subtitle``consent.subtitleSecondary`
### 修改的檔案(本次修復)
- `client/src/i18n/index.ts`:註解 + __DEV__ 下印出 language 與 consent.title
- `client/app/(splash)/splash.tsx`useTranslation 取 i18n + __DEV__ 下印出 consent 畫面時的 language / title / subtitle
- `client/package.json``start:clean``ios:clean``clean:cache`
- `client/src/i18n/ALL_COPY.md`:故障排除與 consent 繁中只改 zh-TW.json 的說明

View File

@@ -0,0 +1,137 @@
# iPad Adaptation技术计划
## 1. 计划目标
基于 `spec.md`,落地“全应用 iPad 适配(含 iOS Widget”实施方案确保
- 客户端所有页面在 iPad 下可用、可读、可交互;
-**竖屏** 为主目标完成逐页适配与验收;
- iOS Widget 在 iPad 相关尺寸下展示稳定;
- 全过程保持 **iPhone 零回归**
## 2. 默认技术决策
- **适配方式**React Native 断点 + 条件样式(不改 iPhone 基线参数)。
- **iPad 判定**`Platform.OS === 'ios' && Math.min(width, height) >= 768`
- **尺寸获取**:统一使用 `useWindowDimensions()`,避免 `Dimensions.get()` 静态值问题。
- **布局原则**:页面采用“最大内容宽度 + 居中 + 弹性留白”。
- **改造策略**:按页面逐个交付,单页完成后冻结,待确认再进入下一页。
## 3. 工程配置基线(先决条件)
### 3.1 原生配置核查
- `app.json``expo.ios.supportsTablet = true`(已要求)。
- iOS 工程目标设备族支持 iPad`TARGETED_DEVICE_FAMILY` 需包含 `1,2`)。
- 方向策略与产品要求一致(当前优先竖屏;若仅竖屏,则 iPad 也收敛为竖屏策略)。
- 避免 iPad 以 iPhone 兼容模式运行(该模式会导致系统黑边)。
### 3.2 运行模式与验收环境
- iPad 模拟器:至少 1 台主流尺寸(如 11-inch
- iPad 真机:至少 1 台(若可用),用于确认系统级显示行为。
- iPhone 回归机型:至少小屏 + 大屏各 1 个。
## 4. 页面适配实施策略(逐页)
### 4.1 页面分批顺序
1. 启动链路:`splash / consent / index`
2. Onboarding 全流程页
3. 主应用页:`home`、详情/弹层、设置、收藏等
4. 边缘页:`modal``not-found`、其他辅助页
### 4.2 单页改造标准模板
每页按以下模板执行:
1. 梳理页面结构:首屏视觉区、正文区、底部操作区、浮层区。
2. 抽离 iPad 分支参数:最大宽度、字号、行高、间距、按钮尺寸。
3. 保持 iPhone 参数不变:原样式分支保留。
4. 自测:
- iPad 竖屏显示完整;
- 文案不截断、按钮可点击;
- iPhone 关键路径不回归。
5. 输出截图与验收点,待确认后冻结该页。
### 4.3 可复用样式基线(建议)
- 页面容器:`flex: 1` + `width/height: '100%'` + `alignSelf: 'stretch'`
- 内容最大宽度:按页面类型设置(例如 560/620/680 分级),统一居中。
- 底部操作区:基于安全区与窗口高度计算,不使用硬编码魔法值。
- 文案区限制最大阅读宽度iPad 适度增大字号与行高。
- 图片/插画:按比例缩放,优先保持构图稳定,不压缩变形。
## 5. iOS Widget 适配计划
### 5.1 覆盖范围
- 小/中/大组件在 iPad 下的展示一致性;
- 文案长度、换行与截断策略;
- 图形与文本的层级、边距、留白;
- 浅色模式基线(深色模式按资源情况补充)。
### 5.2 实施要点
- 统一组件内边距与字号层级映射;
- 对长文本提供优雅截断(避免溢出与跳变);
- 验证不同语言长度对布局影响;
- 输出尺寸矩阵截图作为最终验收材料。
## 6. iPhone 零回归保障
- 所有 iPad 适配均以条件分支或断点参数实现;
- 禁止直接覆盖 iPhone 基线字号/间距;
- 每完成一页,执行 iPhone 冒烟回归:
- 启动流程;
- Onboarding 关键交互;
- Home 关键按钮与弹层;
- Settings/Favorites 基础可用性。
## 7. 验收清单与交付物
### 7.1 页面级验收清单
- 布局完整:无重叠、无错位、无异常黑边;
- 文案可读:不截断(可接受预期截断场景需说明);
- 交互可用:触控面积合理、按钮不被遮挡;
- 状态一致:加载/空态/异常态显示正常;
- iPhone 回归:关键路径无变化。
### 7.2 交付物
- 每页适配说明(改动点 + 参数策略);
- iPad 改前/改后截图;
- iPhone 回归截图;
- Widget 各尺寸截图;
- 最终页面覆盖矩阵与验收记录。
## 8. 分阶段里程碑
1. **M1 基线搭建**
- 完成原生配置核查与适配基线工具/约定;
- 输出页面清单与验收模板。
2. **M2 页面逐页适配**
- 按既定顺序逐页改造;
- 每页交付后等待确认,再继续下一页。
3. **M3 Widget 收口**
- 完成 iPad 尺寸矩阵验证;
- 修复文本/间距/层级问题。
4. **M4 全量回归与发布前验收**
- iPad 全页验收 + iPhone 零回归确认;
- 形成最终验收文档。
## 9. 风险与应对
- **风险**:页面存在大量固定像素值,局部改动引发联动。
**应对**:参数分层、按区块替换、单页冻结机制。
- **风险**iPad 多窗口/舞台管理导致尺寸波动。
**应对**:以竖屏全屏为主验收,同时保留窗口变化最小兼容策略。
- **风险**:多语言文案长度影响 widget 与页面稳定性。
**应对**:统一截断/换行规则,增加长文案样本回归。

View File

@@ -0,0 +1,92 @@
# iPad Adaptation Spec
## Background
当前应用主要按 iPhone 体验实现iPad 上存在以下问题:
- 页面布局在竖屏/横屏或不同窗口尺寸下出现留黑边、内容拥挤、元素比例失衡。
- 各页面对 iPad 的适配策略不统一,样式行为不可预测。
- iOS Widget 在 iPad 场景下缺少完整的尺寸与排版一致性规范。
本需求定义“全应用 iPad 适配”高层规范,覆盖客户端全部页面与 iOS 小组件,确保不回归现有 iPhone 体验。
## Goals
1. 为客户端所有页面建立统一的 iPad 适配规范(优先竖屏,兼容 iPad 常见窗口模式)。
2. 完成全部页面在 iPad 下的布局、间距、字号、交互可用性优化。
3. 完成 iOS Widget 在 iPad 相关尺寸上的视觉与信息层级适配。
4. 明确“iPad 适配不影响 iPhone”的约束、验收与回归策略。
## Non-Goals
- 不重做品牌视觉与核心交互流程。
- 不引入与 iPad 适配无关的新业务功能。
- 不调整后端接口契约(除非为 Widget 展示字段做最小兼容补充)。
## Scope
### In Scope
- `client/app/` 下所有用户可见页面启动、协议、onboarding、home、modal、not-found 等)。
- 共享组件与页面级组件在 iPad 场景下的布局策略容器宽度、断点、字号、触控面积、safe area 处理)。
- iPad 竖屏主流程视觉一致性与可用性。
- iOS Widget小/中/大尺寸)在 iPad 的展示、文本截断、间距与点击目标。
- iPad 相关工程配置核查(如设备家族支持、方向策略与运行模式)。
### Out of Scope
- Android 平板专项适配。
- Web 端平板适配。
- 新增 widget 类型或新增推荐策略。
## Core Requirements
### R1. 统一适配基线
- 定义 iPad 判定与布局断点策略,避免各页面各自实现。
- 页面默认使用“内容最大宽度 + 居中 + 弹性留白”模式,不出现视觉黑边误判。
- 明确安全区、状态栏、底部操作区在 iPad 下的通用规则。
### R2. 页面逐页适配
- 按页面清单逐页交付,单页可独立验收。
- 每页适配需覆盖:首屏构图、正文可读性、底部操作区、长文案换行与触控可用性。
- 已完成页面进入“冻结状态”,未经确认不回改。
### R3. iPhone 零回归
- 所有 iPad 样式调整必须使用条件分支或断点方案,不修改 iPhone 基线参数。
- 每次页面适配后执行 iPhone 快速回归(关键路径与关键组件)。
### R4. iOS Widget 适配
- 覆盖 iPad 下 widget 尺寸与展示密度差异,保证文本与图形不溢出、不遮挡。
- 小组件与主 App 的主题、字体层级、文案截断策略保持一致。
- 提供 widget 预览/截图验收基线(至少包含浅色模式)。
### R5. 验收与质量
- 建立页面级验收清单:布局完整性、可读性、点击可达性、状态一致性、异常文案表现。
- 关键页面提供 iPad 对比截图(改前/改后)与 iPhone 回归截图。
- 适配完成后输出覆盖清单,确保无遗漏页面。
## Acceptance Criteria
1. 应用在 iPad 真机/模拟器上以 iPad 模式运行,不出现 iPhone 兼容模式导致的系统黑边。
2. 全部页面在 iPad 竖屏下通过视觉与交互验收,页面无明显错位、截断、重叠。
3. iPhone 主流尺寸下关键路径无样式与交互回归。
4. iOS Widget 在 iPad 对应尺寸下通过展示验收。
5. 提供最终“页面覆盖矩阵 + 验收记录”。
## Risks
- 现有页面存在大量固定像素值,逐页改造可能引入局部联动风险。
- iPad 多窗口/舞台管理会带来额外窗口尺寸变化,需要明确支持级别。
- Widget 文案长度受多语言影响,需预留截断与回退策略。
## Milestones (High-Level)
1. 基线与清单:完成断点策略、页面与组件清单、验收模板。
2. 页面适配:按“启动链路 -> onboarding -> 主页面 -> 弹层/边缘页面”逐页交付。
3. Widget 适配:完成尺寸验证与视觉一致性收口。
4. 全量回归iPad 全页检查 + iPhone 零回归确认 + 发布前验收。

View File

@@ -0,0 +1,151 @@
# iPad Adaptation任务清单
> 说明:本清单由 `plan.md` 拆解,强调“详细、可执行、可验收”。
> 执行规则:完成后将 `- [ ]` 改为 `- [x]`;阻塞项需补充阻塞原因与解除条件。
> 约束:所有 iPad 改动不得影响 iPhone 现有适配。
## 0. 基线与准备
- [ ] **T0-1 建立页面与组件盘点清单**
- 输出:`client/app/` 页面清单 + 关键共享组件清单(含负责人/优先级)
- 验收清单覆盖启动链路、onboarding、home、settings、favorites、modal、not-found
- [ ] **T0-2 建立验收模板(页面级)**
- 输出统一验收模板布局、文案、交互、状态、iPhone 回归、截图)
- 验收:模板可用于每页独立签收
- [x] **T0-3 建立 iPad 适配基线工具函数/约定**
- 内容:统一 `isTablet` 判定、宽度分级560/620/680、容器基线写法
- 验收:至少在 1 个页面实际接入并可复用
## 1. 原生配置修正(黑边先决条件)
- [x] **T1-1 修正 iOS 目标设备族为 iPhone+iPad**
- 文件:`client/ios/client.xcodeproj/project.pbxproj`
- 要求:主 App targetDebug/Release`TARGETED_DEVICE_FAMILY` 包含 `1,2`
- 验收iPad 运行不再是 iPhone 兼容模式
- [x] **T1-2 校验方向策略与产品要求一致(优先竖屏)**
- 文件:`client/app.json``client/ios/client/Info.plist`
- 要求iPad 方向策略与“竖屏优先”一致,不引入系统级黑边
- 验收iPad 竖屏全屏显示稳定
- [ ] **T1-3 设备验证(基础冒烟)**
- 场景iPad 模拟器(至少 11-inch+ iPhone 两档尺寸
- 验收:启动页无系统黑边,应用可正常进入主流程
## 2. 启动链路页面适配(第一批)
- [x] **T2-1 适配 `app/(splash)/splash.tsx`(同意页)**
- 范围:首屏构图、文案可读、底部按钮区、安全区
- 要求iPad 使用独立参数分支iPhone 参数保持不变
- 验收iPad 竖屏无错位、无遮挡iPhone 对比无回归
- [x] **T2-2 适配 `app/index.tsx`(启动分发页)**
- 范围:加载态容器尺寸、跳转前视觉稳定性
- 验收iPad 下不闪烁、不出现异常留边
- [ ] **T2-3 启动链路验收与冻结**
- 输出iPad 改前/改后截图 + iPhone 回归截图
- 验收:产品确认后标记“冻结”,不再改动本批页面
## 3. Onboarding 全流程适配(第二批)
- [x] **T3-1 适配 `components/onboarding/OnboardingLayout.tsx`**
- 范围:标题区、进度区、内容最大宽度、顶部操作区
- 验收iPad 竖屏布局层级清晰,交互区域可达
- [x] **T3-2 适配 `components/onboarding/NameInputStep.tsx`**
- 范围:输入卡片宽度、键盘抬升、底部按钮区
- 验收iPad 输入过程不卡位、不遮挡按钮
- [x] **T3-3 适配 `components/onboarding/SelectionStep.tsx`**
- 范围:选项卡宽度/间距、滚动区底部留白、底部按钮区
- 验收:末项可见且不被按钮覆盖
- [x] **T3-4 适配 `components/onboarding/ReminderStep.tsx`**
- 范围:数字区比例、加减按钮间距、完成按钮区域
- 验收iPad 读数清晰,触控误触率低
- [x] **T3-5 适配 `app/(onboarding)/onboarding.tsx`(流程壳)**
- 范围步骤切换稳定性、loading 态布局一致性
- 验收:全流程在 iPad 竖屏可连续通过
- [ ] **T3-6 Onboarding 批次验收与冻结**
- 输出:逐页截图 + 关键交互录屏(可选) + iPhone 回归截图
- 验收:确认后冻结本批页面
## 4. 主应用页面适配(第三批)
- [x] **T4-1 适配 `app/(app)/home.tsx`**
- 范围:卡片区、顶部入口、底部操作、空/加载状态
- 验收iPad 信息层级清晰,无文本溢出
- [ ] **T4-2 适配 Home 相关弹层组件**
- 范围:`components/home/` 下弹层、设置卡片、协议入口弹窗
- 验收:弹层在 iPad 下尺寸与点击区合理
- [ ] **T4-3 适配收藏/设置相关页面与入口**
- 范围Favorites、Settings 及关联子组件
- 验收:列表与信息卡在 iPad 下无拥挤/空旷失衡
- [ ] **T4-4 主应用批次验收与冻结**
- 输出:关键页面截图 + 回归记录
- 验收:产品确认后冻结
## 5. 边缘页面与通用组件适配(第四批)
- [x] **T5-1 适配边缘路由页面**
- 范围:`modal``+not-found`、其他辅助页
- 验收iPad 下无明显样式异常
- [ ] **T5-2 清理固定像素高风险点**
- 范围:扫描固定宽高/绝对定位集中区域,替换为分支参数
- 验收:高风险点清单完成闭环
- [ ] **T5-3 通用组件收口**
- 范围:复用组件(如 Sheet、按钮、卡片容器统一 iPad 参数
- 验收:跨页面表现一致
## 6. iOS Widget iPad 适配
- [ ] **T6-1 盘点 Widget 展示尺寸与当前问题**
- 范围Small/Medium/Large 在 iPad 下的展示差异
- 验收:输出问题矩阵(文字溢出/留白/层级)
- [ ] **T6-2 调整 Widget 排版参数**
- 范围:边距、字号、行高、文本截断策略
- 验收:各尺寸均无溢出、无遮挡、层级清晰
- [ ] **T6-3 多语言长文案回归**
- 范围:至少 TC/EN 长短文案样本
- 验收:不同语言下展示稳定
- [ ] **T6-4 Widget 验收截图归档**
- 输出:各尺寸截图(浅色模式必选)
- 验收:可用于最终发布验收材料
## 7. 全量回归与发布前验收
- [ ] **T7-1 iPad 全页面走查**
- 范围:按页面清单逐项验证布局/交互/状态
- 验收:无 P0/P1 视觉与交互问题
- [ ] **T7-2 iPhone 零回归冒烟**
- 范围启动、onboarding、home、settings、favorites、关键弹层
- 验收:关键路径行为与样式无回归
- [ ] **T7-3 输出最终覆盖矩阵与验收记录**
- 输出:页面覆盖表、问题清单、处理结论、剩余风险
- 验收:可直接作为发布前审阅材料
## 8. 收尾与文档同步(全部完成后执行)
- [ ] **T8-1 更新 `spec_kit/overview.md` 对应条目**
- 要求:新增 `iPad Adaptation` 小节,记录目标、范围、阶段产物、完成状态
- 验收:`overview.md` 可一眼看出该需求“任务已全部执行完毕”
- [ ] **T8-2 标记本文件完成状态**
- 要求:`task.md` 全部条目改为 `[x]`,并在文件顶部补“已完成日期/负责人”
- 验收:任务清单闭环

View File

@@ -39,6 +39,7 @@
- **已完成编码(阶段性)**
- 客户端:新增 `client_user_id`UUID v4生成与持久化每日提醒次数范围修正为 **05**0 表示关闭)
- 客户端Onboarding 结束页(每日提醒)在用户选择次数 > 0 时**直接触发系统权限申请**;授权后获取 Expo Push Token 并调用后端 `register/preferences`(移除单独的 push 引导页)
- 客户端Onboarding 问卷完成后“开通推送权限”流程增加 **loading 态**(完成按钮转圈 + 全页禁用交互,避免重复触发/重复上报)
- 客户端:个人主页“每日提醒”弹窗移除测试模式强制无权限逻辑,改为真实读取系统权限;并在开关/点击 OK 时同步后端偏好
- 客户端:新增推送接口封装 `client/src/services/pushApi.ts`token 获取、register/preferences/get、自动上报时区与 locale并携带用户画像供后端 Push 模板使用)
- 后端:新增 Push 数据模型 + Alembic 迁移(`push_tokens` / `push_preferences` / `push_send_log`
@@ -48,6 +49,8 @@
- ETA 发送任务(幂等:同一用户同一天同一 slot 只发一次;用户中途关闭/降次数会跳过)
- 发送文案复用推荐模块 `scene="push"`(降风险)
- 后端:`pytest` 全量通过27 passed
- 客户端:新增通知点击消费链路,支持前后台/冷启动点击每日推荐 Push 后,将文案暂存并在进入 `home` 时优先展示
- 后端:每日推荐 Push payload 新增 `target_screen/home_text/content_id/deep_link`,保证客户端点击通知后可恢复首页展示上下文
## Project Bootstrap
@@ -88,7 +91,9 @@
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
- 清理未接入编译的 WidgetKit 骨架残留:移除磁盘上的 `client/ios/MindfulnessWidget/` 文件,并从 `client/ios/client.xcodeproj/project.pbxproj` 删除对应工程引用(避免 Xcode 显示幽灵文件)
- 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”在共享 scheme `Hey Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist``ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题)
- 修复 Xcode Archive 偶发显示 “Generic Xcode Archive”在共享 scheme `Dear Mama` 的 Archive Post-actions 自动补齐 `.xcarchive/Info.plist``ApplicationProperties`,并在缺失时补齐 `Name`/`SchemeName` + 自检提示(根治 Organizer 无法识别主 App、无法分发/上传 TestFlight 的问题)
- Widget 名称与描述支持多语言TC/EN默认 ENWidget Extension 增加 `Localizable.strings``en.lproj` / `zh-Hant.lproj``EmotionWidget.swift` 使用本地化 key 作为 `.configurationDisplayName/.description`
- 个人主页弹窗:小工具入口**暂时隐藏**锁屏小工具说明;桌面小工具引导弹窗标题(繁中/TC更新为“**如何加入小工具**”(并统一弹窗标题使用该文案);品牌文案改为 **Dear Mama**(含引导搜索词与 Widget 标题)
## Text Wrap
@@ -169,6 +174,10 @@
- `spec_kit/Text Wrap/modules/integration/tasks.md`
- **接入情况**
- HomeAPP已在 `client/app/(app)/home.tsx` 接入 `wrapText()` 渲染 `wrappedText`(含 `\n`
- iOS WidgetWidgetKitApp 侧在写入 `widget.dailyReco.v1` 缓存时,额外生成 `wrapped_text_by_family`small/medium/large并写入 App GroupWidget 侧按 `WidgetFamily` 优先读取该字段渲染(保证换行一致且无需在 Extension 内跑 JS
- **近期变更**
- Widget 接入:`client/src/modules/dailyWidgetReco/index.ts` 生成 `wrapped_text_by_family``client/ios/情绪小组件/EmotionWidget.swift` 按 family 读取;`client/app/(app)/home.tsx` 前台触发一次“尽力而为”的补齐/刷新
- Home 排版风格微调:支持 `scoringOverrides`,在 Home 里对 TC 做“更偏好标点停顿/更好看”的权重与理想宽度微调(不影响默认 v1
## Splash Consent
@@ -195,6 +204,9 @@
- 后端新增内置协议内容页:
- `GET /v1/legal/privacy`
- `GET /v1/legal/terms`
- `GET /v1/legal/support`(技术支持页面,提供审核可用的公开支持信息)
- 协议展示策略调整:`/v1/legal/privacy``/v1/legal/terms``/v1/legal/support` 在携带 `Accept-Language` 时按语言单语展示EN/TC缺省时展示 EN + TCEN 在前、TC 在后)
- 协议页面语义修复HTML 根节点 `lang` 属性不再写死,改为随页面实际语言输出(单语 en/zh-Hant双语默认 en
- 客户端新增协议接口封装 `client/src/services/legalApi.ts`
- 客户端工程化:新增统一 HTTP 封装 `client/src/utils/http.ts`baseURL/超时/JSON/统一错误),并将 `legalApi.ts` / `recoApi.ts` 接入
- 客户端接入两处入口:`app/(splash)/splash.tsx``components/home/ProfileModal.tsx`

View File

@@ -1,14 +1,14 @@
Hey Mama Terms of Use
Dear Mama Terms of Use
Last updated: February 2026
Welcome to Hey Mama (“the App,” “we,” or “us”).
Welcome to Dear Mama (“the App,” “we,” or “us”).
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
1. Intended Audience
Hey Mama is intended for adults only.
Dear Mama is intended for adults only.
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
2. Services Provided
Hey Mama provides text-based content and features, including but not limited to:
Dear Mama provides text-based content and features, including but not limited to:
- Daily affirmations and mindfulness text
- User-configured reminders and push notifications
- Home screen widgets displaying affirmation text
@@ -50,17 +50,17 @@ Updated versions will be made available within the App or related pages. Continu
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
---
Hey Mama 使用條款
Dear Mama 使用條款
最後更新日期2026 年 2 月
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
歡迎使用 Dear Mama以下簡稱「本 App」、「我們」
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App即表示您已閱讀、理解並同意遵守本條款。
1. 服務對象與使用資格
Hey Mama 僅供成年人使用intended for adults
Dear Mama 僅供成年人使用intended for adults
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
2. 服務內容
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
Dear Mama 提供以文字形式為主的內容與功能,包括但不限於:
- 每日肯定語與正念文字內容
- 使用者設定的提醒與推送通知
- 桌面小組件顯示肯定語文字

View File

@@ -1,18 +1,18 @@
Hey Mama | Privacy Policy
Dear Mama | Privacy Policy
Last updated: February 2026
1. Introduction
Welcome to Hey Mama (“the App,” “we,” “us”).
Welcome to Dear Mama (“the App,” “we,” “us”).
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
2. Data Controller and Scope
The App is operated and maintained by the Hey Mama team.
The App is operated and maintained by the Dear Mama team.
This Privacy Policy applies to information processing activities related to your use of the App.
3. Information We Collect
3.1 Information You Provide
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
Dear Mama does not require account registration and does not require you to provide personally identifiable information.
During your use of the App, you may optionally provide or generate the following information:
- Reminder settings (e.g., reminder frequency)
- Text content you view, save as favorites, or create within the App (if available)
@@ -46,7 +46,7 @@ We do not:
- Use your data for third-party advertising purposes
7. Third-Party Services
Hey Mama currently does not integrate third-party advertising or marketing services.
Dear Mama currently does not integrate third-party advertising or marketing services.
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
@@ -54,7 +54,7 @@ If we later integrate third-party analytics or technical services, we will updat
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
9. Minors
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
Dear Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
10. Changes to This Privacy Policy
@@ -62,17 +62,17 @@ We may update this Privacy Policy from time to time. The updated version will be
---
Hey Mama隱私權政策
Dear Mama隱私權政策
最後更新日期2026 年 2 月
一、前言
歡迎使用 Hey Mama以下簡稱「本 App」、「我們」
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
歡迎使用 Dear Mama以下簡稱「本 App」、「我們」
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Dear Mama 時,我們如何收集、使用、保存與保護相關資訊。
當您下載、存取或使用本 App即表示您已閱讀、理解並同意本隱私權政策之內容。
二、我們收集的資訊
1. 使用者主動提供的資訊
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
Dear Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
在使用過程中,您可能會選擇性提供或產生以下資訊:
- 提醒設定(例如提醒頻率)
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
@@ -86,7 +86,7 @@ Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
三、推送通知
在取得您同意後,Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
在取得您同意後,Dear Mama 可能會向您發送提醒推送,例如每日肯定語提示。
- 推送內容僅包含一般文字資訊
- 不包含任何敏感個人資料
- 您可隨時於裝置系統設定中關閉通知功能
@@ -104,14 +104,14 @@ Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身
- 將資料用於第三方廣告投放
六、第三方服務
目前 Hey Mama 未整合第三方廣告或行銷服務。
目前 Dear Mama 未整合第三方廣告或行銷服務。
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
七、資料保存與安全
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
八、未成年人說明
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
Dear Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
若您為未成年人,請在監護人同意與陪同下使用本 App。
九、隱私權政策的變更