From 897f49f2abe7751a3f32d6205e0381ec51c0f913 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 18 May 2026 16:47:29 +0800 Subject: [PATCH] feat(app-expo): tiered large-text presets with English-friendly default Replace the boolean large-text flag with three global typography tiers, defaulting new installs to the smallest tier when English is in effect while preserving legacy storage and Chinese defaults. Add a profile sub-screen to pick the tier and unit tests for storage resolution. Co-authored-by: Cursor --- app-expo/design-tokens.json | 20 + app-expo/src/app/(main)/chapter/[id].tsx | 4 +- app-expo/src/app/(main)/conversation/[id].tsx | 143 +++-- app-expo/src/app/(main)/large-text.tsx | 46 ++ app-expo/src/app/(tabs)/profile.tsx | 26 +- app-expo/src/components/screen-header.tsx | 60 ++- app-expo/src/core/app-settings-context.tsx | 55 +- app-expo/src/core/settings/app-settings.ts | 79 ++- app-expo/src/core/typography-context.tsx | 21 +- app-expo/src/i18n/generated/resources.ts | 500 +++++++++--------- app-expo/src/i18n/locales/en/profile.json | 12 +- app-expo/src/i18n/locales/zh/profile.json | 10 +- .../settings/app-settings-large-text.test.ts | 31 ++ 13 files changed, 663 insertions(+), 344 deletions(-) create mode 100644 app-expo/src/app/(main)/large-text.tsx create mode 100644 app-expo/tests/core/settings/app-settings-large-text.test.ts diff --git a/app-expo/design-tokens.json b/app-expo/design-tokens.json index 506c93b..df887f4 100644 --- a/app-expo/design-tokens.json +++ b/app-expo/design-tokens.json @@ -220,6 +220,26 @@ "lineHeightTight": 28, "lineHeightLoose": 34, "lineHeightXLoose": 40 + }, + "xxlarge": { + "headingLarge": 50, + "headingMedium": 37, + "headingSmall": 32, + "titleLarge": 32, + "titleMedium": 28, + "titleSmall": 24, + "bodyLarge": 32, + "bodyMedium": 30, + "bodySmall": 20, + "captionLarge": 20, + "captionMedium": 18, + "captionSmall": 17, + "sectionTitle": 18, + "badge": 14, + "lineHeightNormal": 34, + "lineHeightTight": 32, + "lineHeightLoose": 39, + "lineHeightXLoose": 46 } } } diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index c364fa6..35701bd 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -360,7 +360,7 @@ function ReadingSettingsModal({ export default function ChapterScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const { preferences: { fontSize, @@ -448,7 +448,7 @@ export default function ChapterScreen() { const headerOccupiedHeight = getScreenHeaderLayoutMetrics(insets, { useSafeArea: true, variant: 'reading', - largeText, + largeTextLevel, typography, }).totalHeight; diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 4e264d7..e66984f 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -1151,7 +1151,7 @@ export default function ConversationScreen() { const { t } = useTranslation('conversation'); const { t: tApp } = useTranslation('app'); const typography = useTypography(); - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const { user } = useSession(); const { data: profile } = useProfile(); @@ -1165,101 +1165,166 @@ export default function ConversationScreen() { return c ? c.toUpperCase() : '?'; }, [user?.nickname, profile?.nickname]); - /** 大字模式:对话气泡与输入使用更大一档,与设置中的「大字」一致 */ + /** 大字档位:气泡与输入在全局 Typography 基础上再分层放大 */ const chatBubbleTextStyle = useMemo( () => ({ - fontSize: largeText ? typography.headingMedium : typography.bodyLarge, - lineHeight: largeText - ? typography.lineHeightXLoose - : typography.lineHeightLoose, + fontSize: + largeTextLevel >= 2 + ? typography.headingLarge + : largeTextLevel >= 1 + ? typography.headingMedium + : typography.bodyLarge, + lineHeight: + largeTextLevel >= 1 + ? typography.lineHeightXLoose + : typography.lineHeightLoose, fontWeight: '400' as const, }), - [typography, largeText], + [typography, largeTextLevel], ); - /** 大字模式:朗读图标与触控区与气泡字号同档放大 */ - const chatReadAloudIconSize = largeText ? 24 : 20; - const chatReadAloudButtonSize = largeText ? 52 : 44; + /** 朗读图标与触控区与气泡字号同档放大 */ + const chatReadAloudIconSize = + largeTextLevel >= 2 ? 28 : largeTextLevel >= 1 ? 24 : 20; + const chatReadAloudButtonSize = + largeTextLevel >= 2 ? 58 : largeTextLevel >= 1 ? 52 : 44; const chatVoiceDurationStyle = useMemo(() => { - const fs = largeText ? typography.headingSmall : typography.titleMedium; + const fs = + largeTextLevel >= 2 + ? typography.headingMedium + : largeTextLevel >= 1 + ? typography.headingSmall + : typography.titleMedium; return { fontSize: fs, lineHeight: Math.ceil(fs * 1.25), fontWeight: '500' as const, }; - }, [typography, largeText]); + }, [typography, largeTextLevel]); const chatTypingLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodySmall : typography.captionLarge, + fontSize: + largeTextLevel >= 2 + ? typography.bodyMedium + : largeTextLevel >= 1 + ? typography.bodySmall + : typography.captionLarge, lineHeight: typography.lineHeightNormal, }), - [typography, largeText], + [typography, largeTextLevel], ); const headerTitleFontStyle = useMemo( () => ({ - fontSize: largeText ? typography.headingMedium : typography.headingSmall, - lineHeight: largeText - ? Math.ceil(typography.headingMedium * 1.4) - : Math.ceil(typography.headingSmall * 1.28), + fontSize: + largeTextLevel >= 2 + ? typography.headingLarge + : largeTextLevel >= 1 + ? typography.headingMedium + : typography.headingSmall, + lineHeight: + largeTextLevel >= 2 + ? Math.ceil(typography.headingLarge * 1.4) + : largeTextLevel >= 1 + ? Math.ceil(typography.headingMedium * 1.4) + : Math.ceil(typography.headingSmall * 1.28), }), - [typography, largeText], + [typography, largeTextLevel], ); const headerTtsSwitchLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodySmall : typography.captionLarge, + fontSize: + largeTextLevel >= 2 + ? typography.bodyMedium + : largeTextLevel >= 1 + ? typography.bodySmall + : typography.captionLarge, fontWeight: '600' as const, color: CHAT_COLORS.primary, }), - [typography, largeText], + [typography, largeTextLevel], ); - const inputLineHeight = largeText - ? typography.lineHeightLoose - : typography.lineHeightNormal; + const inputLineHeight = + largeTextLevel >= 2 + ? typography.lineHeightXLoose + : largeTextLevel >= 1 + ? typography.lineHeightLoose + : typography.lineHeightNormal; const inputTextStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodyLarge : typography.bodyMedium, + fontSize: + largeTextLevel >= 2 + ? typography.bodyLarge + : largeTextLevel >= 1 + ? typography.bodyLarge + : typography.bodyMedium, lineHeight: inputLineHeight, color: CHAT_COLORS.onSurface, maxHeight: inputLineHeight * 4, }), - [typography, inputLineHeight, largeText], + [typography, inputLineHeight, largeTextLevel], ); const connectionNoticeTitleStyle = useMemo(() => { - const fs = largeText ? typography.titleSmall : typography.captionLarge; + const fs = + largeTextLevel >= 2 + ? typography.titleMedium + : largeTextLevel >= 1 + ? typography.titleSmall + : typography.captionLarge; return { fontSize: fs, lineHeight: Math.ceil(fs * 1.28), fontWeight: '700' as const, }; - }, [typography, largeText]); + }, [typography, largeTextLevel]); const connectionNoticeBodyStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodySmall : typography.captionLarge, + fontSize: + largeTextLevel >= 2 + ? typography.bodyMedium + : largeTextLevel >= 1 + ? typography.bodySmall + : typography.captionLarge, lineHeight: typography.lineHeightLoose, }), - [typography, largeText], + [typography, largeTextLevel], ); const sendButtonLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodyLarge : typography.bodyMedium, + fontSize: + largeTextLevel >= 2 + ? typography.titleSmall + : largeTextLevel >= 1 + ? typography.bodyLarge + : typography.bodyMedium, fontWeight: '500' as const, }), - [typography, largeText], + [typography, largeTextLevel], ); const voiceRecordLabelStyle = useMemo( () => ({ - fontSize: largeText ? typography.bodyLarge : typography.bodyMedium, - lineHeight: largeText - ? typography.lineHeightLoose - : typography.lineHeightNormal, + fontSize: + largeTextLevel >= 2 + ? typography.titleSmall + : largeTextLevel >= 1 + ? typography.bodyLarge + : typography.bodyMedium, + lineHeight: + largeTextLevel >= 1 + ? typography.lineHeightLoose + : typography.lineHeightNormal, }), - [typography, largeText], + [typography, largeTextLevel], ); const voiceRecordDurationStyle = useMemo( () => ({ - fontSize: largeText ? typography.captionLarge : typography.captionMedium, + fontSize: + largeTextLevel >= 2 + ? typography.titleSmall + : largeTextLevel >= 1 + ? typography.captionLarge + : typography.captionMedium, lineHeight: typography.lineHeightNormal, }), - [typography, largeText], + [typography, largeTextLevel], ); const statusBadgeTextStyle = useMemo( () => ({ diff --git a/app-expo/src/app/(main)/large-text.tsx b/app-expo/src/app/(main)/large-text.tsx new file mode 100644 index 0000000..a900e94 --- /dev/null +++ b/app-expo/src/app/(main)/large-text.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { Check } from 'lucide-react-native'; + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { ScreenHeader } from '@/components/screen-header'; +import { useAppSettings } from '@/hooks/use-app-settings'; + +export default function LargeTextScreen() { + const { t } = useTranslation('profile'); + const { largeTextLevel, largeTextOptions, changeLargeTextLevel } = + useAppSettings(); + + return ( + + + + {largeTextOptions.map((opt) => { + const isSelected = opt.value === largeTextLevel; + return ( + void changeLargeTextLevel(opt.value)} + className="flex-row items-center justify-between border-b border-border py-4 active:bg-muted" + > + + {opt.label} + + {opt.description} + + + {isSelected && ( + + )} + + ); + })} + + + ); +} diff --git a/app-expo/src/app/(tabs)/profile.tsx b/app-expo/src/app/(tabs)/profile.tsx index 31f54aa..90a6a32 100644 --- a/app-expo/src/app/(tabs)/profile.tsx +++ b/app-expo/src/app/(tabs)/profile.tsx @@ -73,11 +73,13 @@ function RowButton({ } function LanguageRow({ + icon: LeadingIcon = Globe, label, description, currentLabel, onPress, }: { + icon?: LucideIcon; label: string; description: string; currentLabel: string; @@ -89,7 +91,7 @@ function LanguageRow({ onPress={onPress} > - + {label} @@ -168,8 +170,8 @@ export default function ProfileScreen() { languageOptions, themeName, themeOptions, - largeText, - changeLargeText, + largeTextLevel, + largeTextOptions, darkMode, changeDarkMode, } = useAppSettings(); @@ -188,6 +190,8 @@ export default function ProfileScreen() { const currentThemeLabel = themeOptions.find((o) => o.value === themeName)?.label ?? tApp('theme.default'); + const currentLargeTextLabel = + largeTextOptions.find((o) => o.value === largeTextLevel)?.label ?? ''; const avatarUri = resolveApiMediaUrl(user?.avatar_url ?? null); @@ -252,13 +256,15 @@ export default function ProfileScreen() { onPress={() => router.push('/(main)/theme')} /> )} - + {settingsReady && ( + router.push('/(main)/large-text')} + /> + )} = 2 ? 20 : level >= 1 ? 18 : 16; + const titleRowPaddingV = level >= 2 ? 10 : level >= 1 ? 8 : 4; + const backTouchMin = + level >= 2 + ? BACK_HIT_MIN_XLARGE + : level >= 1 + ? BACK_HIT_MIN_LARGE + : BACK_HIT_MIN; + const { variant, typography, largeTextLevel } = opts; const titleFontSize = variant === 'chat' - ? largeText - ? typography.headingMedium - : typography.headingSmall + ? largeTextLevel >= 2 + ? typography.headingLarge + : largeTextLevel >= 1 + ? typography.headingMedium + : typography.headingSmall : Math.max(typography.titleLarge, typography.headingSmall); + const lhChatMul = largeTextLevel >= 2 ? 1.52 : largeTextLevel >= 1 ? 1.45 : 1.38; + const lhChatPad = largeTextLevel >= 2 ? 16 : largeTextLevel >= 1 ? 14 : 10; + const lhDefaultPad = + largeTextLevel >= 2 ? 12 : largeTextLevel >= 1 ? 10 : 8; const titleLineMin = variant === 'chat' - ? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) + - (largeText ? 14 : 10) - : Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8); + ? Math.ceil(titleFontSize * lhChatMul) + lhChatPad + : Math.ceil(titleFontSize * 1.32) + lhDefaultPad; const titleRowMinHeight = Math.max(backTouchMin, titleLineMin); const titleRowOuterHeight = titleRowMinHeight + 2 * titleRowPaddingV; const paddingTop = opts.useSafeArea ? Math.max(insets.top, 12) : 12; @@ -120,17 +136,25 @@ export function ScreenHeader({ useSafeArea = true, }: ScreenHeaderProps) { const insets = useSafeAreaInsets(); - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const typography = useTypography(); const colors = VARIANT_COLORS[variant]; - const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE; + const backIconSize = + largeTextLevel >= 2 + ? BACK_ICON_SIZE_XLARGE + : largeTextLevel >= 1 + ? BACK_ICON_SIZE_LARGE + : BACK_ICON_SIZE; const handleBack = onBack ?? (() => router.back()); const bgColor = backgroundColor ?? colors.background; const titleColor = colors.title; const iconColor = colors.icon ?? colors.iconSecondary; + const hitAndroidTb = largeTextLevel >= 2 ? 12 : largeTextLevel >= 1 ? 10 : 6; + const hitIosTb = largeTextLevel >= 2 ? 10 : largeTextLevel >= 1 ? 8 : 4; + /** * 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。 * 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。 @@ -145,7 +169,7 @@ export function ScreenHeader({ } = getScreenHeaderLayoutMetrics(insets, { useSafeArea, variant, - largeText, + largeTextLevel, typography, }); @@ -192,14 +216,14 @@ export function ScreenHeader({ hitSlop={ Platform.OS === 'android' ? { - top: largeText ? 10 : 6, - bottom: largeText ? 10 : 6, + top: hitAndroidTb, + bottom: hitAndroidTb, right: 8, left: BACK_EXTRA_HIT_LEFT, } : { - top: largeText ? 8 : 4, - bottom: largeText ? 8 : 4, + top: hitIosTb, + bottom: hitIosTb, left: 2, right: 4, } diff --git a/app-expo/src/core/app-settings-context.tsx b/app-expo/src/core/app-settings-context.tsx index 391789f..46b02e6 100644 --- a/app-expo/src/core/app-settings-context.tsx +++ b/app-expo/src/core/app-settings-context.tsx @@ -12,15 +12,16 @@ import { clearAppLanguage, getAppLanguage, getDarkMode, - getLargeText, + getLargeTextLevel, getThemeName, setAppLanguage, setDarkMode, - setLargeText, + setLargeTextLevel, setThemeName, supportedLanguages, THEME_NAMES, type AppLanguage, + type LargeTextLevel, type ThemeName, } from '@/core/settings/app-settings'; import i18n, { syncLanguageWithDevice } from '@/i18n'; @@ -32,8 +33,11 @@ type AppSettingsContextValue = { hasLanguageOverride: boolean; languageOptions: { code: AppLanguage | 'system'; label: string }[]; changeLanguage: (lang: AppLanguage | 'system') => Promise; - largeText: boolean; - changeLargeText: (value: boolean) => Promise; + largeTextLevel: LargeTextLevel; + /** 兼容:>=1 表示开启「大字」相关局部加量 */ + largeTextComfort: boolean; + changeLargeTextLevel: (value: LargeTextLevel) => Promise; + largeTextOptions: { value: LargeTextLevel; label: string; description: string }[]; darkMode: boolean; changeDarkMode: (value: boolean) => Promise; themeName: ThemeName; @@ -46,9 +50,10 @@ const AppSettingsContext = createContext(null); export function AppSettingsProvider({ children }: PropsWithChildren) { const { setColorScheme } = useColorScheme(); const { t } = useTranslation('app'); + const { t: tProfile } = useTranslation('profile'); const [language, setLanguageState] = useState(null); - const [largeText, setLargeTextState] = useState(true); + const [largeTextLevel, setLargeTextLevelState] = useState(0); const [darkMode, setDarkModeState] = useState(false); const [themeName, setThemeNameState] = useState('default'); const [ready, setReady] = useState(false); @@ -57,15 +62,15 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { let cancelled = false; async function load() { - const [lang, large, dark, theme] = await Promise.all([ + const [lang, level, dark, theme] = await Promise.all([ getAppLanguage(), - getLargeText(), + getLargeTextLevel(), getDarkMode(), getThemeName(), ]); if (cancelled) return; setLanguageState(lang); - setLargeTextState(large); + setLargeTextLevelState(level); setDarkModeState(dark); setThemeNameState(theme); if (dark) setColorScheme('dark'); @@ -90,9 +95,9 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { } }, []); - const changeLargeText = useCallback(async (value: boolean) => { - await setLargeText(value); - setLargeTextState(value); + const changeLargeTextLevel = useCallback(async (value: LargeTextLevel) => { + await setLargeTextLevel(value); + setLargeTextLevelState(value); }, []); const changeDarkMode = useCallback( @@ -124,14 +129,38 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { })), ]; + const largeTextOptions: { + value: LargeTextLevel; + label: string; + description: string; + }[] = [ + { + value: 0, + label: tProfile('appExperience.largeTextLevel.standard'), + description: tProfile('appExperience.largeTextLevel.standardDesc'), + }, + { + value: 1, + label: tProfile('appExperience.largeTextLevel.large'), + description: tProfile('appExperience.largeTextLevel.largeDesc'), + }, + { + value: 2, + label: tProfile('appExperience.largeTextLevel.extraLarge'), + description: tProfile('appExperience.largeTextLevel.extraLargeDesc'), + }, + ]; + const value: AppSettingsContextValue = { ready, language: (language ?? i18n.resolvedLanguage ?? 'zh') as AppLanguage, hasLanguageOverride: language !== null, languageOptions, changeLanguage, - largeText, - changeLargeText, + largeTextLevel, + largeTextComfort: largeTextLevel >= 1, + changeLargeTextLevel, + largeTextOptions, darkMode, changeDarkMode, themeName, diff --git a/app-expo/src/core/settings/app-settings.ts b/app-expo/src/core/settings/app-settings.ts index d0204e1..56308d6 100644 --- a/app-expo/src/core/settings/app-settings.ts +++ b/app-expo/src/core/settings/app-settings.ts @@ -1,3 +1,4 @@ +import { getLocales } from 'expo-localization'; import { Platform } from 'react-native'; import { @@ -6,7 +7,11 @@ import { setSecureItem, } from '@/core/storage/secure'; -import { supportedLanguages, type AppLanguage } from '@/i18n/resources'; +import { + fallbackLanguage, + supportedLanguages, + type AppLanguage, +} from '@/i18n/resources'; import { THEME_NAMES, type ThemeName } from '@/constants/theme-bridge'; @@ -18,6 +23,7 @@ import { const KEY_LANGUAGE = 'app_settings_language'; const KEY_LARGE_TEXT = 'app_settings_large_text'; +const KEY_LARGE_TEXT_LEVEL = 'app_settings_large_text_level'; const KEY_DARK_MODE = 'app_settings_dark_mode'; const KEY_THEME_NAME = 'app_settings_theme_name'; const KEY_TTS_SPEAK_DEFAULT = 'app_settings_tts_speak_default'; @@ -62,14 +68,73 @@ export async function clearAppLanguage(): Promise { await deleteStored(KEY_LANGUAGE); } -export async function getLargeText(): Promise { - const v = await getStored(KEY_LARGE_TEXT); - if (v == null || v === '') return true; - return v === 'true'; +export type LargeTextLevel = 0 | 1 | 2; + +function parseStoredLargeTextLevel(raw: string | null): LargeTextLevel | null { + if (raw === '0' || raw === '1' || raw === '2') { + return Number(raw) as LargeTextLevel; + } + return null; } -export async function setLargeText(value: boolean): Promise { - await setStored(KEY_LARGE_TEXT, value ? 'true' : 'false'); +function deviceLanguageFromLocales(): AppLanguage { + const locale = getLocales()[0]; + const tag = locale?.languageCode ?? locale?.languageTag; + if (!tag) return fallbackLanguage; + return tag.toLowerCase().startsWith('zh') ? 'zh' : 'en'; +} + +async function effectiveAppLanguage(): Promise { + const override = await getAppLanguage(); + return override ?? deviceLanguageFromLocales(); +} + +/** 无存储时的默认档位:英文关大字、中文沿用偏大默认 */ +export function defaultLargeTextLevelForLanguage(lang: AppLanguage): LargeTextLevel { + return lang === 'en' ? 0 : 1; +} + +/** + * 纯函数:由原始存储值与当前生效语言解析档位(供单测与实现共用)。 + */ +export function computeLargeTextLevelFromStorage( + levelRaw: string | null, + legacyLargeTextRaw: string | null, + effectiveLanguage: AppLanguage, +): LargeTextLevel { + const parsed = parseStoredLargeTextLevel(levelRaw); + if (parsed !== null) return parsed; + if (legacyLargeTextRaw === 'true') return 1; + if (legacyLargeTextRaw === 'false') return 0; + return defaultLargeTextLevelForLanguage(effectiveLanguage); +} + +export async function getLargeTextLevel(): Promise { + const [levelRaw, legacyRaw, lang] = await Promise.all([ + getStored(KEY_LARGE_TEXT_LEVEL), + getStored(KEY_LARGE_TEXT), + effectiveAppLanguage(), + ]); + const level = computeLargeTextLevelFromStorage(levelRaw, legacyRaw, lang); + + const hasValidLevel = parseStoredLargeTextLevel(levelRaw) !== null; + if (hasValidLevel && legacyRaw != null && legacyRaw !== '') { + await deleteStored(KEY_LARGE_TEXT); + } else if ( + !hasValidLevel && + legacyRaw != null && + legacyRaw !== '' + ) { + await setStored(KEY_LARGE_TEXT_LEVEL, String(level)); + await deleteStored(KEY_LARGE_TEXT); + } + + return level; +} + +export async function setLargeTextLevel(level: LargeTextLevel): Promise { + await setStored(KEY_LARGE_TEXT_LEVEL, String(level)); + await deleteStored(KEY_LARGE_TEXT); } export async function getDarkMode(): Promise { diff --git a/app-expo/src/core/typography-context.tsx b/app-expo/src/core/typography-context.tsx index 61b7f55..046a5b4 100644 --- a/app-expo/src/core/typography-context.tsx +++ b/app-expo/src/core/typography-context.tsx @@ -1,10 +1,9 @@ /** * Global typography from design tokens. * - * - **关大字模式** → `typography.large`(舒适基准,不用过小的 `normal` 档) - * - **开大字模式** → `typography.xlarge`(一级页、正文等整体再放大一档) + * **大字档位**对应全局 token:`large` / `xlarge` / `xxlarge`。 * - * 对话气泡等仍可在页面内用 `largeText` 做更大一级的 token 选择。 + * 对话气泡等仍在页面内用 `largeTextLevel` 做相对当前全局 token 的再放大。 * * Uses React Context instead of NativeWind vars() because vars() returns empty * objects on native (iOS/Android). See: @@ -17,11 +16,12 @@ import React, { type PropsWithChildren, } from 'react'; +import type { LargeTextLevel } from '@/core/settings/app-settings'; import { useAppSettings } from '@/hooks/use-app-settings'; import tokens from '../../design-tokens.json'; -export type TypographyScale = 'large' | 'xlarge'; +export type TypographyScale = 'large' | 'xlarge' | 'xxlarge'; export type TypographyTokens = Record; @@ -30,13 +30,20 @@ const TypographyContext = createContext(null); const SCALE: Record = { large: tokens.typography.large as TypographyTokens, xlarge: tokens.typography.xlarge as TypographyTokens, + xxlarge: tokens.typography.xxlarge as TypographyTokens, }; +function scaleForLargeTextLevel(level: LargeTextLevel): TypographyScale { + if (level >= 2) return 'xxlarge'; + if (level >= 1) return 'xlarge'; + return 'large'; +} + export function TypographyProvider({ children }: PropsWithChildren) { - const { largeText } = useAppSettings(); + const { largeTextLevel } = useAppSettings(); const typography = useMemo( - () => SCALE[largeText ? 'xlarge' : 'large'], - [largeText], + () => SCALE[scaleForLargeTextLevel(largeTextLevel)], + [largeTextLevel], ); return ( diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index 8335323..06a321b 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -1,250 +1,260 @@ // This file is automatically generated by i18next-cli. Do not edit manually. interface Resources { - app: { - languages: { - en: 'English'; - system: 'System'; - zh: 'Chinese'; - }; - name: 'Life Echo'; - tabs: { - conversations: 'Chats'; - explore: 'Explore'; - home: 'Home'; - memoir: 'Memoir'; - profile: 'Profile'; - }; - theme: { - default: 'Default'; - }; - }; - auth: { - login: { - codeLabel: 'Verification Code'; - getCode: 'Get Code'; - getCodeCountdown: 'Retry in {{seconds}}s'; - networkError: 'Network error. Please try again later.'; - phoneLabel: 'Phone Number'; - phonePlaceholder: 'Enter your phone number'; - privacyPolicy: 'Privacy Policy'; - submit: 'Login'; - termsAnd: 'and'; - termsIntro: 'I agree to the'; - termsRequired: 'Please agree to the User Agreement and Privacy Policy first'; - termsRequiredConfirm: 'OK'; - termsRequiredTitle: 'Agreement Required'; - userAgreement: 'User Agreement'; - welcomeSubtitle: 'Some lives grow richer the more you savor them.'; - welcomeTitle: 'Welcome back'; - }; - }; - common: { - chapterLabel: ''; - chapterReading: { - backgroundColor: ''; - bgPureWhite: ''; - bgSepia: ''; - close: ''; - fontSize: ''; - readingSettings: ''; - typography: ''; - }; - continueWriting: ''; - docs: 'Docs'; - emptySubtitle: ''; - emptyTitle: ''; - readMemory: ''; - startChapter: ''; - statusDrafting: ''; - statusLocked: ''; - statusPending: ''; - wordsCount: ''; - }; - conversation: { - addMore: 'More'; - agentName: 'Life Echo'; - assistantReplying: 'Replying…'; - cancel: 'Cancel'; - cancelRecording: 'Cancel recording'; - cannotReadAloud: 'Read unavailable'; - chatQueueSendTimeout: 'Connection timed out. Check your network and try again.'; - chatTitle: 'Conversation'; - chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.'; - chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.'; - chatUnavailableTitle: 'Chat unavailable'; - confirm: 'OK'; - confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.'; - connectionConnected: 'Connected'; - connectionConnecting: 'Connecting...'; - connectionDisconnected: 'Disconnected'; - createError: 'Unable to create conversation. Please check your network and try again.'; - delete: 'Delete'; - deleteConversation: 'Delete Conversation'; - emptyGreetingSubtitle: 'Chat with your companion and record your stories.'; - greetingTitle: 'Say Hello'; - inputPlaceholder: 'Type a message...'; - inputPlaceholderVoice: 'Type here or hold the mic to speak...'; - me: 'Me'; - readAloudAgain: 'Play again'; - readAloudPause: 'Pause reading'; - readAloudResume: 'Resume reading'; - readAloudRequest: 'Read aloud'; - readAloudRequestFailed: 'Could not start playback. Check your connection.'; - readAloudNoMessageId: 'This message is not ready for on-demand reading yet. Pull to refresh or try again.'; - readingAloud: 'Reading aloud…'; - recentChats: 'Recent Chats'; - recordingPermissionDenied: 'Microphone permission is required to record'; - recordingStartFailed: 'Unable to start recording. Please try again.'; - resumeChatSubtitle: 'Open your latest conversation to keep talking.'; - resumeChatTitle: 'Continue chatting'; - send: 'Send'; - startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.'; - stopReadingAloud: 'Stop reading aloud'; - switchToText: 'Switch to text input'; - switchToVoice: 'Switch to voice input'; - tapToEndRecording: 'Tap to end'; - tapToStartRecording: 'Tap to start recording'; - ttsThisTurn: 'Speak'; - ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.'; - topicSuggestionsDismiss: 'Hide'; - timeDaysAgo_one: '{{count}} day ago'; - timeDaysAgo_other: '{{count}} days ago'; - timeHoursAgo_one: '{{count}} hour ago'; - timeHoursAgo_other: '{{count}} hours ago'; - timeJustNow: 'Just now'; - timeMinutesAgo_one: '{{count}} minute ago'; - timeMinutesAgo_other: '{{count}} minutes ago'; - viewAll: 'View All'; - voiceMessagePreview: 'Voice message'; - }; - explore: {}; - home: {}; - legal: { - titlePrivacy: 'Privacy Policy'; - titleTerms: 'User Agreement'; - }; - memoir: { - chapterLabel: 'Chapter {{index}}'; - chapterReading: { - back: 'Back'; - backgroundColor: 'Background'; - bgPureWhite: 'White'; - bgSepia: 'Sepia'; - cancel: 'Cancel'; - chapterNotFound: 'Chapter not found'; - close: 'Close'; - confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.'; - deleteChapter: 'Delete Chapter'; - deleteChapterAction: 'Delete'; - fontSans: 'Sans'; - fontSerif: 'Serif'; - fontSize: 'Font Size'; - fontSizeDefault: 'Medium'; - fontSizeLarge: 'Large'; - fontSizeSmall: 'Small'; - readingSettings: 'Reading Settings'; - settings: 'Settings'; - typography: 'Typography'; - }; - continueWriting: 'Continue Writing'; - emptySubtitle: 'Chat with your companion to record your stories'; - emptyTitle: 'No memoir yet'; - frameworkChapters: { - chapter1: 'Childhood and upbringing'; - chapter2: 'Education and young adulthood'; - chapter3: 'Early career'; - chapter4: 'Major achievements and peak moments'; - chapter5: 'Setbacks, challenges, and turning points'; - chapter6: 'Family and relationships'; - chapter7: 'Beliefs and values'; - chapter8: 'Life summary'; - }; - loadErrorMessage: 'Could not load chapters'; - loadErrorRetry: 'Retry'; - pageTitle: 'Memoir'; - readMemory: 'Read Memory'; - startChapter: 'Start Writing'; - statusDrafting: 'Drafting'; - statusLocked: 'Locked'; - statusPending: 'Pending'; - wordsCount: '{{count}} words'; - }; - profile: { - about: { - aboutUs: 'About Us'; - title: 'About'; - }; - appExperience: { - language: 'Language'; - languageDesc: 'Display language'; - largeText: 'Large Text'; - largeTextDesc: 'Make reading easier'; - nightMode: 'Night Mode'; - nightModeDesc: 'Use dark theme'; - theme: 'Theme'; - themeDesc: 'Color theme'; - title: 'App Experience'; - }; - dataPrivacy: { - deleteAll: 'Delete All Data'; - deleteUnderDevelopment: 'Delete data feature is under development.'; - exportAll: 'Export All Data'; - exportUnderDevelopment: 'Export feature is under development.'; - purgeDialogCancel: 'Cancel'; - purgeDialogConfirm: 'Delete permanently'; - purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.'; - purgeDialogTitle: 'Final confirmation'; - purgeInputLabel: 'Confirmation phrase'; - purgeInputPlaceholder: 'Type the phrase shown above'; - purgeOpenConfirm: 'I understand, continue'; - purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:'; - purgeSubmitting: 'Deleting…'; - purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.'; - purgeWarningTitle: 'Before you continue'; - title: 'Data & Privacy'; - }; - editAvatar: 'Edit Profile Picture'; - helpSupport: { - faq: 'FAQ'; - feedback: 'Feedback & Support'; - feedbackPageTitle: 'Share your thoughts'; - title: 'Help & Support'; - }; - personalInfo: { - avatarPresetFailed: 'Could not set preset avatar'; - avatarUploadFailed: 'Could not upload avatar'; - birthPlacePlaceholder: 'Birthplace'; - birthYearPlaceholder: 'Birth year'; - cancel: 'Cancel'; - changeAvatar: 'Change photo'; - chooseFromLibrary: 'Choose from library'; - choosePreset: 'Preset avatars'; - grewUpPlaceholder: 'Where you grew up'; - libraryPermissionDenied: 'Photo library access is required to pick an image'; - nickname: 'Nickname'; - nicknamePlaceholder: 'Enter nickname'; - nicknameRequired: 'Please enter a nickname'; - occupationPlaceholder: 'Occupation'; - presetPickTitle: 'Choose a preset'; - save: 'Save'; - saveFailed: 'Could not save'; - savePartialBody: 'Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.'; - savePartialTitle: 'Partially saved'; - saving: 'Saving…'; - tapAwayToClose: 'Tap outside to close'; - title: 'Personal info'; - }; - signOut: 'Sign Out'; - signingOut: 'Signing out...'; - tier: { - free: 'Free'; - pro: 'Pro'; - pro_plus: 'Pro+'; - test: 'Test'; - }; - userNamePlaceholder: 'User'; - userTier: '{{tier}}'; - }; + "app": { + "languages": { + "en": "English", + "system": "System", + "zh": "Chinese" + }, + "name": "Life Echo", + "tabs": { + "conversations": "Chats", + "explore": "Explore", + "home": "Home", + "memoir": "Memoir", + "profile": "Profile" + }, + "theme": { + "default": "Default" + } + }, + "auth": { + "login": { + "codeLabel": "Verification Code", + "getCode": "Get Code", + "getCodeCountdown": "Retry in {{seconds}}s", + "networkError": "Network error. Please try again later.", + "phoneLabel": "Phone Number", + "phonePlaceholder": "Enter your phone number", + "privacyPolicy": "Privacy Policy", + "submit": "Login", + "termsAnd": "and", + "termsIntro": "I agree to the", + "termsRequired": "Please agree to the User Agreement and Privacy Policy first", + "termsRequiredConfirm": "OK", + "termsRequiredTitle": "Agreement Required", + "userAgreement": "User Agreement", + "welcomeSubtitle": "Some lives grow richer the more you savor them.", + "welcomeTitle": "Welcome back" + } + }, + "common": { + "chapterLabel": "", + "chapterReading": { + "backgroundColor": "", + "bgPureWhite": "", + "bgSepia": "", + "close": "", + "fontSize": "", + "readingSettings": "", + "typography": "" + }, + "continueWriting": "", + "docs": "Docs", + "emptySubtitle": "", + "emptyTitle": "", + "readMemory": "", + "startChapter": "", + "statusDrafting": "", + "statusLocked": "", + "statusPending": "", + "wordsCount": "" + }, + "conversation": { + "addMore": "More", + "agentName": "Life Echo", + "assistantReplying": "Replying…", + "cancel": "Cancel", + "cancelRecording": "Cancel recording", + "cannotReadAloud": "Read unavailable", + "chatQueueSendTimeout": "Connection timed out. Check your network and try again.", + "chatTitle": "Conversation", + "chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.", + "chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.", + "chatUnavailableTitle": "Chat unavailable", + "confirm": "OK", + "confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.", + "connectionConnected": "Connected", + "connectionConnecting": "Connecting...", + "connectionDisconnected": "Disconnected", + "createError": "Unable to create conversation. Please check your network and try again.", + "delete": "Delete", + "deleteConversation": "Delete Conversation", + "emptyGreetingSubtitle": "Chat with your companion and record your stories.", + "greetingTitle": "Say Hello", + "inputPlaceholder": "Type a message...", + "inputPlaceholderVoice": "Type here or hold the mic to speak...", + "me": "Me", + "readAloudAgain": "Play again", + "readAloudNoMessageId": "This message is not ready for on-demand reading yet. Pull to refresh or try again.", + "readAloudPause": "Pause reading", + "readAloudRequest": "Read aloud", + "readAloudRequestFailed": "Could not start playback. Check your connection.", + "readAloudResume": "Resume reading", + "readingAloud": "Reading aloud…", + "recentChats": "Recent Chats", + "recordingPermissionDenied": "Microphone permission is required to record", + "recordingStartFailed": "Unable to start recording. Please try again.", + "resumeChatSubtitle": "Open your latest conversation to keep talking.", + "resumeChatTitle": "Continue chatting", + "send": "Send", + "startNewSubtitle": "Capture a new memory or share your thoughts with your companion.", + "stopReadingAloud": "Stop reading aloud", + "switchToText": "Switch to text input", + "switchToVoice": "Switch to voice input", + "tapToEndRecording": "Tap to end", + "tapToStartRecording": "Tap to start recording", + "timeDaysAgo_one": "{{count}} day ago", + "timeDaysAgo_other": "{{count}} days ago", + "timeHoursAgo_one": "{{count}} hour ago", + "timeHoursAgo_other": "{{count}} hours ago", + "timeJustNow": "Just now", + "timeMinutesAgo_one": "{{count}} minute ago", + "timeMinutesAgo_other": "{{count}} minutes ago", + "topicSuggestionsDismiss": "Hide", + "ttsThisTurn": "Speak", + "ttsThisTurnAccessibility": "When on, assistant replies synthesize speech before text appears.", + "viewAll": "View All", + "voiceMessagePreview": "Voice message" + }, + "explore": { + + }, + "home": { + + }, + "legal": { + "titlePrivacy": "Privacy Policy", + "titleTerms": "User Agreement" + }, + "memoir": { + "chapterLabel": "Chapter {{index}}", + "chapterReading": { + "back": "Back", + "backgroundColor": "Background", + "bgPureWhite": "White", + "bgSepia": "Sepia", + "cancel": "Cancel", + "chapterNotFound": "Chapter not found", + "close": "Close", + "confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.", + "deleteChapter": "Delete Chapter", + "deleteChapterAction": "Delete", + "fontSans": "Sans", + "fontSerif": "Serif", + "fontSize": "Font Size", + "fontSizeDefault": "Medium", + "fontSizeLarge": "Large", + "fontSizeSmall": "Small", + "readingSettings": "Reading Settings", + "settings": "Settings", + "typography": "Typography" + }, + "emptySubtitle": "Chat with your companion to record your stories", + "emptyTitle": "No memoir yet", + "frameworkChapters": { + "chapter1": "Childhood and upbringing", + "chapter2": "Education and young adulthood", + "chapter3": "Early career", + "chapter4": "Major achievements and peak moments", + "chapter5": "Setbacks, challenges, and turning points", + "chapter6": "Family and relationships", + "chapter7": "Beliefs and values", + "chapter8": "Life summary" + }, + "loadErrorMessage": "Could not load chapters", + "loadErrorRetry": "Retry", + "pageTitle": "Memoir", + "readMemory": "Read Memory", + "statusDrafting": "Drafting", + "statusLocked": "Locked", + "statusPending": "Pending", + "wordsCount": "{{count}} words" + }, + "profile": { + "about": { + "aboutUs": "About Us", + "title": "About" + }, + "appExperience": { + "language": "Language", + "languageDesc": "Display language", + "largeText": "Large text", + "largeTextDesc": "Standard, large, or extra-large", + "largeTextLevel": { + "extraLarge": "Extra large", + "extraLargeDesc": "One more step up from Large", + "large": "Large", + "largeDesc": "Larger body text and headings app-wide", + "standard": "Standard", + "standardDesc": "Comfortable default for most screens" + }, + "nightMode": "Night Mode", + "nightModeDesc": "Use dark theme", + "theme": "Theme", + "themeDesc": "Color theme", + "title": "App Experience" + }, + "dataPrivacy": { + "deleteAll": "Delete All Data", + "deleteUnderDevelopment": "Delete data feature is under development.", + "exportAll": "Export All Data", + "exportUnderDevelopment": "Export feature is under development.", + "purgeDialogCancel": "Cancel", + "purgeDialogConfirm": "Delete permanently", + "purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.", + "purgeDialogTitle": "Final confirmation", + "purgeInputLabel": "Confirmation phrase", + "purgeInputPlaceholder": "Type the phrase shown above", + "purgeOpenConfirm": "I understand, continue", + "purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:", + "purgeSubmitting": "Deleting…", + "purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. Profile fields such as birth year, birthplace, where you grew up, and occupation will also be cleared. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.", + "purgeWarningTitle": "Before you continue", + "title": "Data & Privacy" + }, + "editAvatar": "Edit Profile Picture", + "helpSupport": { + "faq": "FAQ", + "feedback": "Feedback & Support", + "feedbackPageTitle": "Share your thoughts", + "title": "Help & Support" + }, + "personalInfo": { + "avatarPresetFailed": "Could not set preset avatar", + "avatarUploadFailed": "Could not upload avatar", + "birthPlacePlaceholder": "Birthplace", + "birthYearPlaceholder": "Birth year", + "cancel": "Cancel", + "changeAvatar": "Change photo", + "chooseFromLibrary": "Choose from library", + "choosePreset": "Preset avatars", + "grewUpPlaceholder": "Where you grew up", + "libraryPermissionDenied": "Photo library access is required to pick an image", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "nicknameRequired": "Please enter a nickname", + "occupationPlaceholder": "Occupation", + "presetPickTitle": "Choose a preset", + "save": "Save", + "saveFailed": "Could not save", + "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", + "savePartialTitle": "Partially saved", + "saving": "Saving…", + "tapAwayToClose": "Tap outside to close", + "title": "Personal info" + }, + "signOut": "Sign Out", + "signingOut": "Signing out...", + "tier": { + "free": "Free", + "pro": "Pro", + "pro_plus": "Pro+", + "test": "Test" + }, + "userNamePlaceholder": "User", + "userTier": "{{tier}}" + } } export default Resources; diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index a1abacf..99072ce 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -6,8 +6,16 @@ "appExperience": { "language": "Language", "languageDesc": "Display language", - "largeText": "Large Text", - "largeTextDesc": "Make reading easier", + "largeText": "Large text", + "largeTextDesc": "Standard, large, or extra-large", + "largeTextLevel": { + "standard": "Standard", + "standardDesc": "Comfortable default for most screens", + "large": "Large", + "largeDesc": "Larger body text and headings app-wide", + "extraLarge": "Extra large", + "extraLargeDesc": "One more step up from Large" + }, "nightMode": "Night Mode", "nightModeDesc": "Use dark theme", "theme": "Theme", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index d27bbcf..22007b7 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -7,7 +7,15 @@ "language": "语言", "languageDesc": "应用显示语言", "largeText": "大字模式", - "largeTextDesc": "让阅读更轻松", + "largeTextDesc": "标准、大字或更大字号", + "largeTextLevel": { + "standard": "标准", + "standardDesc": "舒适阅读(默认较小)", + "large": "大字", + "largeDesc": "正文与标题整体放大", + "extraLarge": "超大", + "extraLargeDesc": "在「大字」基础上再放大一档" + }, "nightMode": "夜间模式", "nightModeDesc": "使用深色主题", "theme": "主题", diff --git a/app-expo/tests/core/settings/app-settings-large-text.test.ts b/app-expo/tests/core/settings/app-settings-large-text.test.ts new file mode 100644 index 0000000..7c4fcaf --- /dev/null +++ b/app-expo/tests/core/settings/app-settings-large-text.test.ts @@ -0,0 +1,31 @@ +import { + computeLargeTextLevelFromStorage, + defaultLargeTextLevelForLanguage, +} from '@/core/settings/app-settings'; + +describe('defaultLargeTextLevelForLanguage', () => { + it('uses level 0 for English', () => { + expect(defaultLargeTextLevelForLanguage('en')).toBe(0); + }); + + it('uses level 1 for Chinese', () => { + expect(defaultLargeTextLevelForLanguage('zh')).toBe(1); + }); +}); + +describe('computeLargeTextLevelFromStorage', () => { + it('prefers valid level key when present', () => { + expect(computeLargeTextLevelFromStorage('2', 'false', 'en')).toBe(2); + expect(computeLargeTextLevelFromStorage('0', 'true', 'zh')).toBe(0); + }); + + it('ignores invalid level key and uses legacy', () => { + expect(computeLargeTextLevelFromStorage('9', 'true', 'en')).toBe(1); + expect(computeLargeTextLevelFromStorage('', 'false', 'zh')).toBe(0); + }); + + it('uses language default when no usable values', () => { + expect(computeLargeTextLevelFromStorage(null, null, 'en')).toBe(0); + expect(computeLargeTextLevelFromStorage('', '', 'zh')).toBe(1); + }); +});