4 Commits

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:05:17 +08:00
雷汀岚
b4ec17fcac onboarding: 转场动画、名字步取消自动跳页、标题个性化招呼语、Skip/提醒步等文案与交互优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:33:53 +08:00
雷汀岚
aa4e1e9947 onboarding: 选项字体与问题一致、底部间距、提醒页文案与移除底部 Skip
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 17:11:13 +08:00
29 changed files with 751 additions and 208 deletions

View File

@@ -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<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,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')}
>
<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>
@@ -577,15 +626,16 @@ export default function HomeScreen() {
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 +665,8 @@ function CircleIconButton({
return (
<Pressable
onPress={onPress}
hitSlop={10}
// 稍微增大可点击区域,提升易用性
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.circleBtn}
@@ -640,9 +691,9 @@ const styles = StyleSheet.create({
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',

View File

@@ -175,18 +175,17 @@ export default function OnboardingScreen() {
}
};
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 (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' && (
<NameInputStep
@@ -233,7 +233,7 @@ export default function OnboardingScreen() {
{currentStep.type === 'reminder' && (
<ReminderStep
value={reminderTimes}
value={Math.max(1, reminderTimes)}
onChange={setReminderTimes}
onFinish={onFinish}
onSkip={() => {

View File

@@ -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() {
/>
</View>
{/* 文案内容 */}
{/* 文案内容:主標題兩行 + 可選二級標題(字號更小、顏色更淺);繁中為元件內常數,其餘用 i18n */}
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
<Text style={styles.titleText}>
{t('consent.title')}
{title}
{'\n'}
{t('consent.subtitle')}
{subtitle}
</Text>
{subtitleSecondary ? (
<Text style={styles.consentSubtitleSecondary}>{subtitleSecondary}</Text>
) : null}
</View>
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
@@ -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,

View File

@@ -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 }) {
</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} 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" />

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

@@ -95,8 +95,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 } 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 (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
@@ -49,16 +93,29 @@ export function OnboardingLayout({
</TouchableOpacity>
</View>
{/* Title & Progress Row */}
{/* Title & Progress Row(名字步骤后第一步且名字非空时显示招呼语 + 问题) */}
<View style={styles.titleRow}>
<Text style={styles.questionTitle}>{title}</Text>
<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,
{
opacity,
transform: [{ translateX }],
},
]}
>
{children}
</View>
</Animated.View>
</SafeAreaView>
</View>
);
@@ -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: {

View File

@@ -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 = () => {
// 允许 050 表示关闭每日提醒
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
<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}>
@@ -48,10 +50,6 @@ export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStep
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
<BtnClicked width={87} height={57} />
</TouchableOpacity>
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
</TouchableOpacity>
</View>
</View>
);
@@ -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,
},
});

View File

@@ -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 (
<View style={styles.container}>
@@ -39,16 +38,11 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
return (
<TouchableOpacity
key={option.id}
style={styles.optionCard}
style={[styles.optionCard, 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 +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',

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, ); }; };
@@ -54,12 +54,12 @@
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 */
@@ -213,7 +202,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -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;
};

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" : "正念"
// 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])
}
}

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" = "一段溫柔提醒,陪你回到當下。";

133
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",
@@ -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"

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: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",

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": "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": "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 “Hey Mama” → add a size you like."
},
"consent": {
"title": "You Are Perfect.",
"subtitle": "Everything\nWill Be Better.",
"title": "Hey 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": "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": "用戶使用協議",

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": "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.",

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": "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",

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": "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",

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": "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": "你本就完美。",

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": "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": "繼續使用即代表你同意<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,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 的說明