Files
life-echo/app-expo/src/core/settings/app-settings.ts
Kevin 897f49f2ab 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>
2026-05-18 16:47:29 +08:00

187 lines
5.7 KiB
TypeScript

import { getLocales } from 'expo-localization';
import { Platform } from 'react-native';
import {
deleteSecureItem,
getSecureItem,
setSecureItem,
} from '@/core/storage/secure';
import {
fallbackLanguage,
supportedLanguages,
type AppLanguage,
} from '@/i18n/resources';
import { THEME_NAMES, type ThemeName } from '@/constants/theme-bridge';
import {
mergeMemoirReadingPreferences,
parseMemoirReadingPreferences,
type MemoirReadingPreferences,
} from './memoir-reading-settings';
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';
const KEY_MEMOIR_READING = 'app_settings_memoir_reading';
const webFallback: Record<string, string> = {};
async function getStored(key: string): Promise<string | null> {
if (Platform.OS === 'web') return webFallback[key] ?? null;
return getSecureItem(key);
}
async function setStored(key: string, value: string): Promise<void> {
if (Platform.OS === 'web') {
webFallback[key] = value;
return;
}
await setSecureItem(key, value);
}
async function deleteStored(key: string): Promise<void> {
if (Platform.OS === 'web') {
delete webFallback[key];
return;
}
await deleteSecureItem(key);
}
export async function getAppLanguage(): Promise<AppLanguage | null> {
const v = await getStored(KEY_LANGUAGE);
if (!v) return null;
return supportedLanguages.includes(v as AppLanguage)
? (v as AppLanguage)
: null;
}
export async function setAppLanguage(lang: AppLanguage): Promise<void> {
await setStored(KEY_LANGUAGE, lang);
}
export async function clearAppLanguage(): Promise<void> {
await deleteStored(KEY_LANGUAGE);
}
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;
}
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> {
const v = await getStored(KEY_DARK_MODE);
return v === 'true';
}
export async function setDarkMode(value: boolean): Promise<void> {
await setStored(KEY_DARK_MODE, value ? 'true' : 'false');
}
export async function getThemeName(): Promise<ThemeName> {
const v = await getStored(KEY_THEME_NAME);
if (!v || !THEME_NAMES.includes(v as ThemeName)) return 'default';
return v as ThemeName;
}
export async function setThemeName(value: ThemeName): Promise<void> {
await setStored(KEY_THEME_NAME, value);
}
/** 会话页「Speak / 本轮朗读」开关是否默认开启(跨会话记忆) */
export async function getTtsSpeakDefault(): Promise<boolean> {
const v = await getStored(KEY_TTS_SPEAK_DEFAULT);
if (v == null || v === '') return false;
return v === 'true';
}
export async function setTtsSpeakDefault(value: boolean): Promise<void> {
await setStored(KEY_TTS_SPEAK_DEFAULT, value ? 'true' : 'false');
}
export async function getMemoirReadingPreferences(): Promise<MemoirReadingPreferences> {
const raw = await getStored(KEY_MEMOIR_READING);
return parseMemoirReadingPreferences(raw);
}
export async function setMemoirReadingPreferences(
patch: Partial<MemoirReadingPreferences>,
): Promise<void> {
const raw = await getStored(KEY_MEMOIR_READING);
const current = parseMemoirReadingPreferences(raw);
const next = mergeMemoirReadingPreferences(current, patch);
await setStored(KEY_MEMOIR_READING, JSON.stringify(next));
}
export { supportedLanguages, THEME_NAMES };
export type { AppLanguage, ThemeName };
export type { MemoirReadingPreferences } from './memoir-reading-settings';