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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
46
app-expo/src/app/(main)/large-text.tsx
Normal file
46
app-expo/src/app/(main)/large-text.tsx
Normal file
@@ -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 (
|
||||
<View className="flex-1 bg-background">
|
||||
<ScreenHeader title={t('appExperience.largeText')} />
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
>
|
||||
{largeTextOptions.map((opt) => {
|
||||
const isSelected = opt.value === largeTextLevel;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
onPress={() => void changeLargeTextLevel(opt.value)}
|
||||
className="flex-row items-center justify-between border-b border-border py-4 active:bg-muted"
|
||||
>
|
||||
<View className="min-w-0 flex-1 shrink pr-4">
|
||||
<Text className="font-medium text-foreground">{opt.label}</Text>
|
||||
<Text variant="bodySmall" className="mt-1 text-muted-foreground">
|
||||
{opt.description}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<Icon as={Check} className="text-primary" size={20} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<View className="flex-row items-center gap-4">
|
||||
<Icon as={Globe} className="text-muted-foreground" size={20} />
|
||||
<Icon as={LeadingIcon} className="text-muted-foreground" size={20} />
|
||||
<View>
|
||||
<Text className="font-medium text-foreground">{label}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
@@ -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')}
|
||||
/>
|
||||
)}
|
||||
<SettingRow
|
||||
icon={Type}
|
||||
label={t('appExperience.largeText')}
|
||||
description={t('appExperience.largeTextDesc')}
|
||||
value={largeText}
|
||||
onValueChange={changeLargeText}
|
||||
/>
|
||||
{settingsReady && (
|
||||
<LanguageRow
|
||||
icon={Type}
|
||||
label={t('appExperience.largeText')}
|
||||
description={t('appExperience.largeTextDesc')}
|
||||
currentLabel={currentLargeTextLabel}
|
||||
onPress={() => router.push('/(main)/large-text')}
|
||||
/>
|
||||
)}
|
||||
<SettingRow
|
||||
icon={Moon}
|
||||
label={t('appExperience.nightMode')}
|
||||
|
||||
@@ -11,11 +11,15 @@ import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
|
||||
import type { TypographyTokens } from '@/core/typography-context';
|
||||
|
||||
import type { LargeTextLevel } from '@/core/settings/app-settings';
|
||||
|
||||
/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
|
||||
const BACK_HIT_MIN = 48;
|
||||
const BACK_HIT_MIN_LARGE = 56;
|
||||
const BACK_HIT_MIN_XLARGE = 62;
|
||||
const BACK_ICON_SIZE = 24;
|
||||
const BACK_ICON_SIZE_LARGE = 30;
|
||||
const BACK_ICON_SIZE_XLARGE = 34;
|
||||
const BACK_EXTRA_HIT_LEFT = 4;
|
||||
|
||||
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
|
||||
@@ -23,7 +27,7 @@ export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
|
||||
export type ScreenHeaderLayoutOpts = {
|
||||
useSafeArea: boolean;
|
||||
variant: ScreenHeaderVariant;
|
||||
largeText: boolean;
|
||||
largeTextLevel: LargeTextLevel;
|
||||
typography: TypographyTokens;
|
||||
};
|
||||
|
||||
@@ -34,21 +38,33 @@ export function getScreenHeaderLayoutMetrics(
|
||||
insets: { top: number },
|
||||
opts: ScreenHeaderLayoutOpts,
|
||||
) {
|
||||
const barPaddingBottom = opts.largeText ? 18 : 16;
|
||||
const titleRowPaddingV = opts.largeText ? 8 : 4;
|
||||
const backTouchMin = opts.largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN;
|
||||
const { variant, typography, largeText } = opts;
|
||||
const level = opts.largeTextLevel;
|
||||
const barPaddingBottom =
|
||||
level >= 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,
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
largeText: boolean;
|
||||
changeLargeText: (value: boolean) => Promise<void>;
|
||||
largeTextLevel: LargeTextLevel;
|
||||
/** 兼容:>=1 表示开启「大字」相关局部加量 */
|
||||
largeTextComfort: boolean;
|
||||
changeLargeTextLevel: (value: LargeTextLevel) => Promise<void>;
|
||||
largeTextOptions: { value: LargeTextLevel; label: string; description: string }[];
|
||||
darkMode: boolean;
|
||||
changeDarkMode: (value: boolean) => Promise<void>;
|
||||
themeName: ThemeName;
|
||||
@@ -46,9 +50,10 @@ const AppSettingsContext = createContext<AppSettingsContextValue | null>(null);
|
||||
export function AppSettingsProvider({ children }: PropsWithChildren) {
|
||||
const { setColorScheme } = useColorScheme();
|
||||
const { t } = useTranslation('app');
|
||||
const { t: tProfile } = useTranslation('profile');
|
||||
|
||||
const [language, setLanguageState] = useState<AppLanguage | null>(null);
|
||||
const [largeText, setLargeTextState] = useState(true);
|
||||
const [largeTextLevel, setLargeTextLevelState] = useState<LargeTextLevel>(0);
|
||||
const [darkMode, setDarkModeState] = useState(false);
|
||||
const [themeName, setThemeNameState] = useState<ThemeName>('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,
|
||||
|
||||
@@ -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<void> {
|
||||
await deleteStored(KEY_LANGUAGE);
|
||||
}
|
||||
|
||||
export async function getLargeText(): Promise<boolean> {
|
||||
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<void> {
|
||||
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<AppLanguage> {
|
||||
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<LargeTextLevel> {
|
||||
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<void> {
|
||||
await setStored(KEY_LARGE_TEXT_LEVEL, String(level));
|
||||
await deleteStored(KEY_LARGE_TEXT);
|
||||
}
|
||||
|
||||
export async function getDarkMode(): Promise<boolean> {
|
||||
|
||||
@@ -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<string, number>;
|
||||
|
||||
@@ -30,13 +30,20 @@ const TypographyContext = createContext<TypographyTokens | null>(null);
|
||||
const SCALE: Record<TypographyScale, TypographyTokens> = {
|
||||
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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
"language": "语言",
|
||||
"languageDesc": "应用显示语言",
|
||||
"largeText": "大字模式",
|
||||
"largeTextDesc": "让阅读更轻松",
|
||||
"largeTextDesc": "标准、大字或更大字号",
|
||||
"largeTextLevel": {
|
||||
"standard": "标准",
|
||||
"standardDesc": "舒适阅读(默认较小)",
|
||||
"large": "大字",
|
||||
"largeDesc": "正文与标题整体放大",
|
||||
"extraLarge": "超大",
|
||||
"extraLargeDesc": "在「大字」基础上再放大一档"
|
||||
},
|
||||
"nightMode": "夜间模式",
|
||||
"nightModeDesc": "使用深色主题",
|
||||
"theme": "主题",
|
||||
|
||||
31
app-expo/tests/core/settings/app-settings-large-text.test.ts
Normal file
31
app-expo/tests/core/settings/app-settings-large-text.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user