diff --git a/client/app/(app)/home.tsx b/client/app/(app)/home.tsx index 6cfbd27..b64c666 100644 --- a/client/app/(app)/home.tsx +++ b/client/app/(app)/home.tsx @@ -55,6 +55,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'; +import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -265,11 +266,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; @@ -377,6 +391,11 @@ export default function HomeScreen() { setIndex(0); fetchNewFeed(); } + + // Widget:前台辅助刷新(尽力而为) + // - 写入 App Group 的 dailyReco 缓存 + // - 生成 wrapped_text_by_family,供 Widget 直接渲染 + ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {}); })(); return () => { cancelled = true; @@ -444,13 +463,41 @@ export default function HomeScreen() { }); }, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]); + // 切换到上一条文案的统一动画逻辑(下滑触发) + const triggerPrevContent = useCallback(() => { + if (busy) return; + setBusy(true); + + // 1. 当前文案向下移动并消失 + translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) }); + opacity.value = withTiming(0, { duration: 300 }, (finished) => { + if (finished) { + // 2. 切换数据索引(循环回退) + const nextIndex = index - 1 < 0 ? Math.max(0, currentFeed.length - 1) : index - 1; + runOnJS(setIndex)(nextIndex); + runOnJS(setLikeFilled)(false); + + // 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(setBusy)(false); + } + }); + } + }); + }, [busy, index, currentFeed.length, translateY, opacity]); + const lastTapRef = useRef(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,9 +522,11 @@ 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)(); } }, }) @@ -549,13 +598,13 @@ export default function HomeScreen() { onPress={() => setThemeOpen(true)} accessibilityLabel={t('home.theme')} > - + setProfileOpen(true)} accessibilityLabel={t('home.profile')} > - + @@ -577,15 +626,16 @@ export default function HomeScreen() { onPress={onPressLike} accessibilityRole="button" accessibilityLabel={t('home.like')} - hitSlop={20} + // 稍微增大可点击区域,提升单手操作成功率 + hitSlop={24} style={styles.reactionInner} > {likeFilled ? ( - + ) : ( )} @@ -615,7 +665,8 @@ function CircleIconButton({ return ( { - // 跳过整个 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 (currentStep.type === 'name') { + onNext(); + } else if (currentStep.type === 'selection') { + setSelections((prev) => ({ ...prev, [currentStep.id]: [] })); + onNext(); + } else if (currentStep.type === 'reminder') { + setReminderTimes(0); + onFinish(); + } }; // 题目为多选:点击切换选中状态 @@ -210,9 +209,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' && ( { diff --git a/client/app/(splash)/splash.tsx b/client/app/(splash)/splash.tsx index bc5fa3e..129f329 100644 --- a/client/app/(splash)/splash.tsx +++ b/client/app/(splash)/splash.tsx @@ -8,6 +8,7 @@ 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'; @@ -15,10 +16,30 @@ 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 [showConsent, setShowConsent] = useState(false); + + // 繁中時強制使用上方常數(含 zh-TW / zh-Hant / zh-Hant-TW),其餘用 i18n + const isZhTW = isTraditionalChineseLocaleTag(i18n.language || ''); + const title = isZhTW ? ZH_TW_CONSENT.title : t('consent.title'); + const subtitle = isZhTW ? ZH_TW_CONSENT.subtitle : t('consent.subtitle'); + const subtitleSecondary = isZhTW ? ZH_TW_CONSENT.subtitleSecondary : t('consent.subtitleSecondary'); + + useEffect(() => { + if (typeof __DEV__ !== 'undefined' && __DEV__ && showConsent) { + console.log('[i18n consent] language=', i18n.language, 'title=', title, 'subtitle=', subtitle); + } + }, [showConsent, i18n.language, title, subtitle]); const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({}); const [linksLoading, setLinksLoading] = useState(false); const mountedRef = useRef(true); @@ -126,13 +147,16 @@ export default function SplashScreen() { /> - {/* 文案内容 */} + {/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */} - {t('consent.title')} + {title} {'\n'} - {t('consent.subtitle')} + {subtitle} + {subtitleSecondary ? ( + {subtitleSecondary} + ) : null} @@ -213,6 +237,14 @@ 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, diff --git a/client/components/home/ProfileModal.tsx b/client/components/home/ProfileModal.tsx index 35c6c60..49b07e7 100644 --- a/client/components/home/ProfileModal.tsx +++ b/client/components/home/ProfileModal.tsx @@ -553,6 +553,8 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () = function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) { const { t, i18n } = useTranslation(); const currentLang = i18n.language; + // 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口 + const showLockScreenWidget = false; // 根据语言选择图片 const widget1 = currentLang === 'en' @@ -570,10 +572,12 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) { - - - {t('widget.lockScreen')} - + {showLockScreenWidget ? ( + + + {t('widget.lockScreen')} + + ) : null} diff --git a/client/components/home/WidgetModal.tsx b/client/components/home/WidgetModal.tsx index 539cb44..10b9b35 100644 --- a/client/components/home/WidgetModal.tsx +++ b/client/components/home/WidgetModal.tsx @@ -13,7 +13,7 @@ export default function WidgetModal({ visible, onClose }: Props) { const { t } = useTranslation(); return ( - + diff --git a/client/components/onboarding/IntentSelectionStep.tsx b/client/components/onboarding/IntentSelectionStep.tsx index 6ecf290..bc46871 100644 --- a/client/components/onboarding/IntentSelectionStep.tsx +++ b/client/components/onboarding/IntentSelectionStep.tsx @@ -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 ( - {t('intent.title')} + {t('intent.title')} {INTENTS.map((intent) => { @@ -35,10 +34,10 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt onPress={() => onToggle(intent.id)} activeOpacity={0.7} > - {intent.icon} - + {intent.icon} + {t(intent.labelKey)} - + ); })} @@ -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', diff --git a/client/components/onboarding/NameInputStep.tsx b/client/components/onboarding/NameInputStep.tsx index c367aef..46724a2 100644 --- a/client/components/onboarding/NameInputStep.tsx +++ b/client/components/onboarding/NameInputStep.tsx @@ -95,8 +95,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp blurOnSubmit={true} onSubmitEditing={() => { Keyboard.dismiss(); - // 有输入时,“完成”直接进入下一步,避免真机卡在键盘上 - if (value.trim().length > 0) onNext(); + // 不再自動跳頁,僅收起鍵盤;前進需點擊底部 ➡️ }} /> diff --git a/client/components/onboarding/OnboardingLayout.tsx b/client/components/onboarding/OnboardingLayout.tsx index 5b864dd..a20d685 100644 --- a/client/components/onboarding/OnboardingLayout.tsx +++ b/client/components/onboarding/OnboardingLayout.tsx @@ -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 } 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,9 +25,48 @@ export function OnboardingLayout({ totalSteps, onSkip, onBack, - showBackButton = false + showBackButton = false, + userName = '', }: OnboardingLayoutProps) { const { t } = useTranslation(); + const showGreeting = currentStep === 1 && userName.trim().length > 0; + const displayName = userName.trim(); + const prevStepRef = useRef(currentStep); + const isFirstRenderRef = useRef(true); + const translateX = useRef(new Animated.Value(0)).current; + const opacity = useRef(new Animated.Value(1)).current; + + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + prevStepRef.current = currentStep; + return; + } + if (prevStepRef.current === currentStep) return; + + const direction = currentStep > prevStepRef.current ? 'forward' : 'back'; + prevStepRef.current = currentStep; + + const startX = direction === 'forward' ? TRANSITION_OFFSET : -TRANSITION_OFFSET; + translateX.setValue(startX); + opacity.setValue(0.72); + + Animated.parallel([ + Animated.timing(translateX, { + toValue: 0, + duration: TRANSITION_DURATION, + useNativeDriver: true, + easing: Easing.out(Easing.cubic), + }), + Animated.timing(opacity, { + toValue: 1, + duration: TRANSITION_DURATION, + useNativeDriver: true, + easing: Easing.out(Easing.cubic), + }), + ]).start(); + }, [currentStep, translateX, opacity]); + return ( @@ -49,16 +93,29 @@ export function OnboardingLayout({ - {/* Title & Progress Row */} + {/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */} - {title} + + {showGreeting && ( + {t('onboardingSurvey.greeting', { name: displayName })} + )} + {title} + ({currentStep}/{totalSteps}) - {/* Content */} - + {/* Content:step 切换时滑动 + 淡入 */} + {children} - + ); @@ -114,18 +171,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: { diff --git a/client/components/onboarding/ReminderStep.tsx b/client/components/onboarding/ReminderStep.tsx index b660495..a48b15a 100644 --- a/client/components/onboarding/ReminderStep.tsx +++ b/client/components/onboarding/ReminderStep.tsx @@ -11,16 +11,16 @@ interface ReminderStepProps { value: number; onChange: (value: number) => void; onFinish: () => void; - onSkip: () => void; + onSkip?: () => void; } -export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) { +export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const handleReduce = () => { - // 允许 0~5;0 表示关闭每日提醒 - if (value > 0) onChange(value - 1); + // 本页最小为 1;不接收提醒请使用右上角 Skip + if (value > 1) onChange(value - 1); }; const handleAdd = () => { @@ -36,7 +36,9 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep {value} - {t('dailyReminder.timesUnit')} + + {value === 1 ? t('dailyReminder.timesUnitSingular') : t('dailyReminder.timesUnit')} + @@ -48,10 +50,6 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep - - - {t('onboarding.skip')} - ); @@ -93,17 +91,5 @@ 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, }, }); diff --git a/client/components/onboarding/SelectionStep.tsx b/client/components/onboarding/SelectionStep.tsx index 2bb9e25..2b9e989 100644 --- a/client/components/onboarding/SelectionStep.tsx +++ b/client/components/onboarding/SelectionStep.tsx @@ -1,9 +1,7 @@ import React from 'react'; -import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; +import { View, StyleSheet, TouchableOpacity, ScrollView, Text } 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'; @@ -25,7 +23,8 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip } const insets = useSafeAreaInsets(); const footerBottom = insets.bottom + 16; const footerButtonHeight = 57; - const footerPaddingBottom = footerBottom + footerButtonHeight + 24; + // 底部留白加大,避免最后一项与按钮边框视觉重叠 + const footerPaddingBottom = footerBottom + footerButtonHeight + 40; return ( @@ -39,16 +38,11 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip } return ( onToggle(option.id)} activeOpacity={0.7} > - {option.label} - {isSelected && ( - - - - )} + {option.label} ); })} @@ -67,7 +61,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 +76,7 @@ const styles = StyleSheet.create({ borderRadius: 20, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'center', paddingHorizontal: 24, marginBottom: 12, shadowColor: '#000', @@ -91,14 +85,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', diff --git a/client/constants/OnboardingTheme.ts b/client/constants/OnboardingTheme.ts index 871489f..022e7ef 100644 --- a/client/constants/OnboardingTheme.ts +++ b/client/constants/OnboardingTheme.ts @@ -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', diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index 9893a78..561a3bb 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -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, ); }; }; @@ -54,12 +54,12 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = ""; }; 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 = ""; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = ""; }; A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = ""; }; - C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = ""; }; C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = ""; }; 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 = ""; - }; + EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -213,7 +202,7 @@ EB3DAFD42F2A5FC100450593 /* Recovered References */ = { isa = PBXGroup; children = ( - A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */, + A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -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; }; diff --git a/client/ios/情绪小组件/EmotionWidget.swift b/client/ios/情绪小组件/EmotionWidget.swift index e389007..2d7cae7 100644 --- a/client/ios/情绪小组件/EmotionWidget.swift +++ b/client/ios/情绪小组件/EmotionWidget.swift @@ -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 = "You’ve 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" : "正念" + // 需求:品牌文案「正念」统一改为 Hey Mama + return "Hey 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, @@ -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]) } } diff --git a/client/ios/情绪小组件/en.lproj/Localizable.strings b/client/ios/情绪小组件/en.lproj/Localizable.strings new file mode 100644 index 0000000..504c5bb --- /dev/null +++ b/client/ios/情绪小组件/en.lproj/Localizable.strings @@ -0,0 +1,3 @@ +"WIDGET_DISPLAY_NAME" = "Emotion Widget"; +"WIDGET_DESCRIPTION" = "A gentle reminder to return to the present."; + diff --git a/client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings b/client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings new file mode 100644 index 0000000..e49c971 --- /dev/null +++ b/client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,3 @@ +"WIDGET_DISPLAY_NAME" = "情緒小組件"; +"WIDGET_DESCRIPTION" = "一段溫柔提醒,陪你回到當下。"; + diff --git a/client/package-lock.json b/client/package-lock.json index c747c50..f305eff 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", @@ -10122,6 +10211,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 +10507,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 +10653,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 +10687,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 +11035,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 +11098,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 +11617,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 +11692,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 +11710,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 +11737,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11652,6 +11752,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 +11830,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 +11963,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" diff --git a/client/package.json b/client/package.json index 093582d..76fd503 100644 --- a/client/package.json +++ b/client/package.json @@ -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:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Hey 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", diff --git a/client/src/features/textWrap/types.ts b/client/src/features/textWrap/types.ts index e037a69..47ab052 100644 --- a/client/src/features/textWrap/types.ts +++ b/client/src/features/textWrap/types.ts @@ -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 | null; /** 可选注入测量实现(APP 场景强烈建议提供;WIDGET 默认不启用) */ measureWidthImpl?: MeasureWidthImpl; + /** + * 可选评分偏好(用于 UI 场景“更好看”的排版风格)。 + * - 不传:使用算法默认 v1 权重与口径(更贴近文档 10.3) + * - 传入:仅在本次 wrapText 调用内生效,不影响全局 + */ + scoringOverrides?: { + /** 覆盖/微调默认权重(整数)。建议只改少数项,例如 TC 的标点断行奖励。 */ + weights?: Partial; + /** 覆盖理想行宽比例(0~1)。更小会更倾向“提前换行”。 */ + idealWidthRatio?: Partial>; + /** 最短偏好比例(相对 idealWidth)。更小会更宽容“短行”。 */ + minPreferredRatio?: number; + /** 末行过短惩罚阈值比例(相对 idealWidth)。更小会更宽容“短末行”。 */ + shortLastLineRatio?: number; + }; /** * 测量缓存隔离 key(可选)。 * 口径建议:`APP|ios|` / `WIDGET|small`。 diff --git a/client/src/features/textWrap/wrapText.ts b/client/src/features/textWrap/wrapText.ts index 77fe397..93f4835 100644 --- a/client/src/features/textWrap/wrapText.ts +++ b/client/src/features/textWrap/wrapText.ts @@ -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 { // scoring:protectedPhrases 从 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, }; // 搜索 diff --git a/client/src/i18n/ALL_COPY.md b/client/src/i18n/ALL_COPY.md index 57ccc86..fe0156f 100644 --- a/client/src/i18n/ALL_COPY.md +++ b/client/src/i18n/ALL_COPY.md @@ -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.*` - - diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 6fb1e42..49f897b 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -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; 'zh-TW': Record }; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const zhTW = require('./locales/zh-TW.json') as Record; /** * 语言码约定: @@ -81,7 +85,8 @@ export async function initI18n(): Promise { await i18n.use(initReactI18next).init({ resources: { - 'zh-TW': { translation: all['zh-TW'] as any }, + // 繁中唯一來源:zh-TW.json(all.json 的 zh-TW 區塊不會被載入) + 'zh-TW': { translation: zhTW }, en: { translation: all.en as any }, }, lng: initialLang, @@ -91,6 +96,18 @@ export async function initI18n(): Promise { 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 區塊' + ); + } } /** diff --git a/client/src/i18n/locales/all.json b/client/src/i18n/locales/all.json index aa4fddc..8751f4c 100644 --- a/client/src/i18n/locales/all.json +++ b/client/src/i18n/locales/all.json @@ -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. We’ll 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": "It’s okay if enabling fails. You can keep using the app." }, "home": { - "title": "Mindfulness", + "title": "Hey 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 “Hey Mama”, choose a widget size you like, then tap “Add Widget”.", "previewDate": "Thu, Jan 29", "previewQuote": "I’m 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 “Hey Mama” → add a size you like." }, "consent": { - "title": "You Are Perfect.", - "subtitle": "Everything\nWill Be Better.", + "title": "Hey mama.", + "subtitle": "You’re 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 {{privacyLabel}}{{privacySuffix}} and {{termsLabel}}{{termsSuffix}}.", + "noticeRich": "By continuing,\nyou agree to the {{privacyLabel}}{{privacySuffix}} and {{termsLabel}}{{termsSuffix}}.", "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": "Hey 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": "搜尋「Hey Mama」,選擇喜歡的尺寸,點「加入小工具」。", "previewDate": "1月29日週四 · 已至臘月十一", "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" }, @@ -308,11 +313,12 @@ "language": "語言", "version": "版本", "widgetTitle": "iOS 小工具", - "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" + "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Hey Mama」 → 添加你喜歡的尺寸。" }, "consent": { - "title": "你很完美。", - "subtitle": "一切\n都會更好。", + "title": "我們知道,", + "subtitle": "當媽媽很不容易。", + "subtitleSecondary": "這裡給你一些溫柔的肯定與提醒", "agree": "同意並繼續", "privacy": "隱私協議", "terms": "用戶使用協議", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index ec20c6a..8d23fd6 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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": "It’s okay if enabling fails. You can keep using the app." }, "home": { - "title": "Mindfulness", + "title": "Hey 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 “Hey Mama” → add a size you like." }, "consent": { "title": "You Are Perfect.", diff --git a/client/src/i18n/locales/es.json b/client/src/i18n/locales/es.json index e6d1b81..79d7817 100644 --- a/client/src/i18n/locales/es.json +++ b/client/src/i18n/locales/es.json @@ -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": "Hey 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 “Hey Mama” → añade el tamaño." }, "consent": { "agree": "Aceptar y Continuar", diff --git a/client/src/i18n/locales/pt.json b/client/src/i18n/locales/pt.json index a09aa32..e594545 100644 --- a/client/src/i18n/locales/pt.json +++ b/client/src/i18n/locales/pt.json @@ -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": "Hey 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 “Hey Mama” → adicione o tamanho." }, "consent": { "agree": "Concordar e Continuar", diff --git a/client/src/i18n/locales/zh-CN.json b/client/src/i18n/locales/zh-CN.json index f774875..bf90671 100644 --- a/client/src/i18n/locales/zh-CN.json +++ b/client/src/i18n/locales/zh-CN.json @@ -12,7 +12,7 @@ "progress": "{{current}}/{{total}}", "next": "下一步", "skip": "跳过", - "skipAll": "跳过整个引导", + "skipAll": "跳过", "q1Title": "你最近的感受更接近哪一种?", "q1Desc": "没有对错,你可以跳过,之后也可以慢慢调整。", "q2Title": "你更希望获得哪种支持?", @@ -33,7 +33,7 @@ "errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。" }, "home": { - "title": "正念", + "title": "Hey 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": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“Hey Mama” → 添加你喜欢的尺寸。" }, "consent": { "title": "你本就完美。", diff --git a/client/src/i18n/locales/zh-TW.json b/client/src/i18n/locales/zh-TW.json index 4e91b8f..a7c3833 100644 --- a/client/src/i18n/locales/zh-TW.json +++ b/client/src/i18n/locales/zh-TW.json @@ -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": "Hey 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": "搜尋「Hey Mama」,選擇喜歡的尺寸,點「加入小工具」。", "previewDate": "1月29日週四 · 已至臘月十一", "previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。" }, "favorites": { "title": "收藏夾", - "empty": "這裡還沒有收藏內容。" + "empty": "這裡還沒有收藏內容。", + "unknownText": "這條文案暫時無法顯示。" }, "settings": { "title": "設定", "language": "語言", "version": "版本", "widgetTitle": "iOS 小工具", - "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。" + "widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Hey Mama」 → 添加你喜歡的尺寸。" }, "consent": { + "title": "我們知道,", + "subtitle": "當媽媽很不容易。", + "subtitleSecondary": "這裡給你一些溫柔的肯定與提醒", "agree": "同意並繼續", "privacy": "隱私協議", - "terms": "用戶使用協議" + "terms": "用戶使用協議", + "notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。", + "noticeRich": "繼續使用即代表你同意《{{privacyLabel}}》{{privacySuffix}}《{{termsLabel}}》{{termsSuffix}}。", + "linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。", + "linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}", + "linkLoadingSuffix": "(載入中…)" + }, + "permissions": { + "notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。" + }, + "language": { + "zhTW": "繁體中文", + "en": "English" + }, + "mock": { + "c1": "你已經很努力了,今天也值得被溫柔對待。", + "c2": "深呼吸三次,把注意力帶回當下。", + "c3": "允許自己慢一點,情緒會像雲一樣飄過。", + "c4": "你不需要完美,你已經足夠好。", + "c5": "把手放在心口,對自己說一句:辛苦了。" } -} - +} \ No newline at end of file diff --git a/client/src/modules/dailyWidgetReco/index.ts b/client/src/modules/dailyWidgetReco/index.ts index fcbb03f..b6fba2f 100644 --- a/client/src/modules/dailyWidgetReco/index.ts +++ b/client/src/modules/dailyWidgetReco/index.ts @@ -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>; final_score?: number; fallback_level_final?: number; }; @@ -56,6 +63,58 @@ function safeJsonParse(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> { + const families: WidgetFamilyKey[] = ['small', 'medium', 'large']; + const out: Partial> = {}; + + 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; +} + 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, }, diff --git a/spec_kit/Splash Consent/overflow.md b/spec_kit/Splash Consent/overflow.md new file mode 100644 index 0000000..eb96333 --- /dev/null +++ b/spec_kit/Splash Consent/overflow.md @@ -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 log:i18n 初始化與開屏 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 的說明