diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index 43608dd..c364fa6 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -23,7 +23,13 @@ import { ScreenHeader, } from '@/components/screen-header'; import { ScreenGutter } from '@/constants/layout'; +import { useMemoirReadingSettings } from '@/core/memoir-reading-settings-context'; import { useTypography } from '@/core/typography-context'; +import type { + MemoirReadingBackground, + MemoirReadingFontFamily, + MemoirReadingFontSize, +} from '@/core/settings/memoir-reading-settings'; import { useAppSettings } from '@/hooks/use-app-settings'; import { MarkdownRenderer, @@ -137,11 +143,7 @@ function StorySegmentCover({ ); } -type FontSize = 'small' | 'default' | 'large'; -type FontFamily = 'serif' | 'sans'; -type BackgroundTheme = 'white' | 'sepia'; - -const FONT_FAMILIES: Record = { +const FONT_FAMILIES: Record = { serif: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ?? 'serif', @@ -153,7 +155,7 @@ const FONT_FAMILIES: Record = { }) ?? 'sans-serif', }; -const BACKGROUND_COLORS: Record = { +const BACKGROUND_COLORS: Record = { white: READING_COLORS.background, sepia: READING_COLORS.backgroundSepia, }; @@ -192,12 +194,12 @@ function ReadingSettingsModal({ }: { visible: boolean; onClose: () => void; - fontSize: FontSize; - fontFamily: FontFamily; - backgroundColor: BackgroundTheme; - onFontSizeChange: (v: FontSize) => void; - onFontFamilyChange: (v: FontFamily) => void; - onBackgroundChange: (v: BackgroundTheme) => void; + fontSize: MemoirReadingFontSize; + fontFamily: MemoirReadingFontFamily; + backgroundColor: MemoirReadingBackground; + onFontSizeChange: (v: MemoirReadingFontSize) => void; + onFontFamilyChange: (v: MemoirReadingFontFamily) => void; + onBackgroundChange: (v: MemoirReadingBackground) => void; t: (key: string) => string; }) { const insets = useSafeAreaInsets(); @@ -359,6 +361,16 @@ export default function ChapterScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); const { largeText } = useAppSettings(); + const { + preferences: { + fontSize, + fontFamily, + background: backgroundTheme, + }, + setFontSize, + setFontFamily, + setBackground: setBackgroundTheme, + } = useMemoirReadingSettings(); const typography = useTypography(); const { width } = useWindowDimensions(); const { t } = useTranslation('memoir'); @@ -367,12 +379,8 @@ export default function ChapterScreen() { const deleteChapter = useDeleteChapter(); const [settingsVisible, setSettingsVisible] = useState(false); - const [fontSize, setFontSize] = useState('default'); - const [fontFamily, setFontFamily] = useState('serif'); - const [backgroundColor, setBackgroundColor] = - useState('white'); - const bgColor = BACKGROUND_COLORS[backgroundColor]; + const bgColor = BACKGROUND_COLORS[backgroundTheme]; if (isLoading) { return ( @@ -610,10 +618,16 @@ export default function ChapterScreen() { onClose={() => setSettingsVisible(false)} fontSize={fontSize} fontFamily={fontFamily} - backgroundColor={backgroundColor} - onFontSizeChange={setFontSize} - onFontFamilyChange={setFontFamily} - onBackgroundChange={setBackgroundColor} + backgroundColor={backgroundTheme} + onFontSizeChange={(v) => { + void setFontSize(v); + }} + onFontFamilyChange={(v) => { + void setFontFamily(v); + }} + onBackgroundChange={(v) => { + void setBackgroundTheme(v); + }} t={t as (key: string) => string} /> diff --git a/app-expo/src/core/memoir-reading-settings-context.tsx b/app-expo/src/core/memoir-reading-settings-context.tsx new file mode 100644 index 0000000..2a35065 --- /dev/null +++ b/app-expo/src/core/memoir-reading-settings-context.tsx @@ -0,0 +1,99 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type PropsWithChildren, +} from 'react'; + +import { + DEFAULT_MEMOIR_READING_PREFERENCES, + type MemoirReadingBackground, + type MemoirReadingFontFamily, + type MemoirReadingFontSize, + type MemoirReadingPreferences, +} from '@/core/settings/memoir-reading-settings'; +import { + getMemoirReadingPreferences, + setMemoirReadingPreferences, +} from '@/core/settings/app-settings'; + +type MemoirReadingSettingsContextValue = { + ready: boolean; + preferences: MemoirReadingPreferences; + setFontSize: (v: MemoirReadingFontSize) => Promise; + setFontFamily: (v: MemoirReadingFontFamily) => Promise; + setBackground: (v: MemoirReadingBackground) => Promise; +}; + +const MemoirReadingSettingsContext = + createContext(null); + +export function MemoirReadingSettingsProvider({ children }: PropsWithChildren) { + const [preferences, setPreferences] = useState( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + const loaded = await getMemoirReadingPreferences(); + if (cancelled) return; + setPreferences(loaded); + setReady(true); + })(); + return () => { + cancelled = true; + }; + }, []); + + const persistAndSet = useCallback(async (patch: Partial) => { + await setMemoirReadingPreferences(patch); + setPreferences((prev) => ({ ...prev, ...patch })); + }, []); + + const setFontSize = useCallback( + (v: MemoirReadingFontSize) => persistAndSet({ fontSize: v }), + [persistAndSet], + ); + + const setFontFamily = useCallback( + (v: MemoirReadingFontFamily) => persistAndSet({ fontFamily: v }), + [persistAndSet], + ); + + const setBackground = useCallback( + (v: MemoirReadingBackground) => persistAndSet({ background: v }), + [persistAndSet], + ); + + const value = useMemo( + () => ({ + ready, + preferences, + setFontSize, + setFontFamily, + setBackground, + }), + [ready, preferences, setFontSize, setFontFamily, setBackground], + ); + + return ( + + {children} + + ); +} + +export function useMemoirReadingSettings(): MemoirReadingSettingsContextValue { + const ctx = useContext(MemoirReadingSettingsContext); + if (!ctx) { + throw new Error( + 'useMemoirReadingSettings must be used within MemoirReadingSettingsProvider', + ); + } + return ctx; +} diff --git a/app-expo/src/core/providers.tsx b/app-expo/src/core/providers.tsx index e4d7780..8357e04 100644 --- a/app-expo/src/core/providers.tsx +++ b/app-expo/src/core/providers.tsx @@ -2,6 +2,7 @@ import React, { type PropsWithChildren } from 'react'; import { initApiClient } from '@/core/api/client'; import { AppSettingsProvider } from '@/core/app-settings-context'; +import { MemoirReadingSettingsProvider } from '@/core/memoir-reading-settings-context'; import { NetworkError } from '@/core/api/types'; import { tokenManager } from '@/core/auth/token-manager'; import { config } from '@/core/config'; @@ -62,7 +63,9 @@ initApiClient({ export function AppProviders({ children }: PropsWithChildren) { return ( - {children} + + {children} + ); } diff --git a/app-expo/src/core/settings/app-settings.ts b/app-expo/src/core/settings/app-settings.ts index 12854bd..d0204e1 100644 --- a/app-expo/src/core/settings/app-settings.ts +++ b/app-expo/src/core/settings/app-settings.ts @@ -10,11 +10,18 @@ import { 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_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 = {}; @@ -95,5 +102,20 @@ export async function setTtsSpeakDefault(value: boolean): Promise { await setStored(KEY_TTS_SPEAK_DEFAULT, value ? 'true' : 'false'); } +export async function getMemoirReadingPreferences(): Promise { + const raw = await getStored(KEY_MEMOIR_READING); + return parseMemoirReadingPreferences(raw); +} + +export async function setMemoirReadingPreferences( + patch: Partial, +): Promise { + 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'; diff --git a/app-expo/src/core/settings/memoir-reading-settings.ts b/app-expo/src/core/settings/memoir-reading-settings.ts new file mode 100644 index 0000000..0245d7c --- /dev/null +++ b/app-expo/src/core/settings/memoir-reading-settings.ts @@ -0,0 +1,70 @@ +export type MemoirReadingFontSize = 'small' | 'default' | 'large'; + +export type MemoirReadingFontFamily = 'serif' | 'sans'; + +export type MemoirReadingBackground = 'white' | 'sepia'; + +export interface MemoirReadingPreferences { + fontSize: MemoirReadingFontSize; + fontFamily: MemoirReadingFontFamily; + background: MemoirReadingBackground; +} + +export const DEFAULT_MEMOIR_READING_PREFERENCES: MemoirReadingPreferences = { + fontSize: 'default', + fontFamily: 'serif', + background: 'white', +}; + +const FONT_SIZES: MemoirReadingFontSize[] = ['small', 'default', 'large']; +const FONT_FAMS: MemoirReadingFontFamily[] = ['serif', 'sans']; +const BACKGROUNDS: MemoirReadingBackground[] = ['white', 'sepia']; + +function isFontSize(v: unknown): v is MemoirReadingFontSize { + return typeof v === 'string' && FONT_SIZES.includes(v as MemoirReadingFontSize); +} + +function isFontFamily(v: unknown): v is MemoirReadingFontFamily { + return typeof v === 'string' && FONT_FAMS.includes(v as MemoirReadingFontFamily); +} + +function isBackground(v: unknown): v is MemoirReadingBackground { + return typeof v === 'string' && BACKGROUNDS.includes(v as MemoirReadingBackground); +} + +export function parseMemoirReadingPreferences( + raw: string | null, +): MemoirReadingPreferences { + if (raw == null || raw.trim() === '') { + return { ...DEFAULT_MEMOIR_READING_PREFERENCES }; + } + try { + const parsed: unknown = JSON.parse(raw); + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ...DEFAULT_MEMOIR_READING_PREFERENCES }; + } + const o = parsed as Record; + const d = DEFAULT_MEMOIR_READING_PREFERENCES; + return { + fontSize: isFontSize(o.fontSize) ? o.fontSize : d.fontSize, + fontFamily: isFontFamily(o.fontFamily) ? o.fontFamily : d.fontFamily, + background: isBackground(o.background) ? o.background : d.background, + }; + } catch { + return { ...DEFAULT_MEMOIR_READING_PREFERENCES }; + } +} + +export function mergeMemoirReadingPreferences( + base: MemoirReadingPreferences, + patch: Partial, +): MemoirReadingPreferences { + return { + fontSize: + patch.fontSize !== undefined ? patch.fontSize : base.fontSize, + fontFamily: + patch.fontFamily !== undefined ? patch.fontFamily : base.fontFamily, + background: + patch.background !== undefined ? patch.background : base.background, + }; +} diff --git a/app-expo/src/features/memoir/markdown-renderer.tsx b/app-expo/src/features/memoir/markdown-renderer.tsx index 0bc2913..0a65357 100644 --- a/app-expo/src/features/memoir/markdown-renderer.tsx +++ b/app-expo/src/features/memoir/markdown-renderer.tsx @@ -9,6 +9,10 @@ import React from 'react'; import { Platform, StyleSheet, Text, View } from 'react-native'; import Markdown from 'react-native-markdown-display'; +import type { + MemoirReadingFontFamily, + MemoirReadingFontSize, +} from '@/core/settings/memoir-reading-settings'; import type { ImageAsset } from './types'; const PLACEHOLDER_RE = /\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}/g; @@ -197,8 +201,8 @@ export interface MarkdownRendererProps { markdown: string; renderedAssets: ImageAsset[]; coverImageUrl: string | null; - fontSize: 'small' | 'default' | 'large'; - fontFamily: 'serif' | 'sans'; + fontSize: MemoirReadingFontSize; + fontFamily: MemoirReadingFontFamily; backgroundColor: string; contentWidth: number; /** 多故事分段时仅首段下沉首字 */ diff --git a/app-expo/tests/core/settings/memoir-reading-settings.test.ts b/app-expo/tests/core/settings/memoir-reading-settings.test.ts new file mode 100644 index 0000000..2b629f2 --- /dev/null +++ b/app-expo/tests/core/settings/memoir-reading-settings.test.ts @@ -0,0 +1,86 @@ +import { + DEFAULT_MEMOIR_READING_PREFERENCES, + mergeMemoirReadingPreferences, + parseMemoirReadingPreferences, +} from '@/core/settings/memoir-reading-settings'; + +describe('parseMemoirReadingPreferences', () => { + it('returns defaults for null and empty', () => { + expect(parseMemoirReadingPreferences(null)).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect(parseMemoirReadingPreferences('')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect(parseMemoirReadingPreferences(' ')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + }); + + it('returns defaults for invalid JSON', () => { + expect(parseMemoirReadingPreferences('not json')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + }); + + it('returns defaults for non-object JSON', () => { + expect(parseMemoirReadingPreferences('[]')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect(parseMemoirReadingPreferences('"x"')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + }); + + it('fills missing or invalid fields from defaults', () => { + expect(parseMemoirReadingPreferences('{}')).toEqual( + DEFAULT_MEMOIR_READING_PREFERENCES, + ); + expect( + parseMemoirReadingPreferences( + JSON.stringify({ + fontSize: 'huge', + fontFamily: 'comic', + background: 'black', + }), + ), + ).toEqual(DEFAULT_MEMOIR_READING_PREFERENCES); + }); + + it('accepts valid partial object', () => { + expect( + parseMemoirReadingPreferences( + JSON.stringify({ fontSize: 'large' }), + ), + ).toEqual( + mergeMemoirReadingPreferences(DEFAULT_MEMOIR_READING_PREFERENCES, { + fontSize: 'large', + }), + ); + expect( + parseMemoirReadingPreferences( + JSON.stringify({ background: 'sepia', fontFamily: 'sans' }), + ), + ).toEqual({ + ...DEFAULT_MEMOIR_READING_PREFERENCES, + background: 'sepia', + fontFamily: 'sans', + }); + }); +}); + +describe('mergeMemoirReadingPreferences', () => { + it('applies only defined patch keys', () => { + const base = { + fontSize: 'small' as const, + fontFamily: 'sans' as const, + background: 'sepia' as const, + }; + expect(mergeMemoirReadingPreferences(base, { fontSize: 'large' })).toEqual({ + fontSize: 'large', + fontFamily: 'sans', + background: 'sepia', + }); + expect(mergeMemoirReadingPreferences(base, {})).toEqual(base); + }); +});