feat(memoir): persist chapter reading prefs globally

Share font size, font family, and background across all memoir chapters via MemoirReadingSettingsProvider and SecureStore (same app-settings pattern). Add parse/merge helpers and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-15 17:23:02 +08:00
parent c4d2a38b09
commit 6f41574bda
7 changed files with 322 additions and 24 deletions

View File

@@ -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<FontFamily, string> = {
const FONT_FAMILIES: Record<MemoirReadingFontFamily, string> = {
serif:
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
'serif',
@@ -153,7 +155,7 @@ const FONT_FAMILIES: Record<FontFamily, string> = {
}) ?? 'sans-serif',
};
const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
const BACKGROUND_COLORS: Record<MemoirReadingBackground, string> = {
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<FontSize>('default');
const [fontFamily, setFontFamily] = useState<FontFamily>('serif');
const [backgroundColor, setBackgroundColor] =
useState<BackgroundTheme>('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}
/>
</View>

View File

@@ -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<void>;
setFontFamily: (v: MemoirReadingFontFamily) => Promise<void>;
setBackground: (v: MemoirReadingBackground) => Promise<void>;
};
const MemoirReadingSettingsContext =
createContext<MemoirReadingSettingsContextValue | null>(null);
export function MemoirReadingSettingsProvider({ children }: PropsWithChildren) {
const [preferences, setPreferences] = useState<MemoirReadingPreferences>(
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<MemoirReadingPreferences>) => {
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 (
<MemoirReadingSettingsContext.Provider value={value}>
{children}
</MemoirReadingSettingsContext.Provider>
);
}
export function useMemoirReadingSettings(): MemoirReadingSettingsContextValue {
const ctx = useContext(MemoirReadingSettingsContext);
if (!ctx) {
throw new Error(
'useMemoirReadingSettings must be used within MemoirReadingSettingsProvider',
);
}
return ctx;
}

View File

@@ -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 (
<AppQueryProvider>
<AppSettingsProvider>{children}</AppSettingsProvider>
<AppSettingsProvider>
<MemoirReadingSettingsProvider>{children}</MemoirReadingSettingsProvider>
</AppSettingsProvider>
</AppQueryProvider>
);
}

View File

@@ -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<string, string> = {};
@@ -95,5 +102,20 @@ 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';

View File

@@ -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<string, unknown>;
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>,
): 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,
};
}

View File

@@ -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;
/** 多故事分段时仅首段下沉首字 */

View File

@@ -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);
});
});