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:
@@ -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>
|
||||
|
||||
99
app-expo/src/core/memoir-reading-settings-context.tsx
Normal file
99
app-expo/src/core/memoir-reading-settings-context.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
70
app-expo/src/core/settings/memoir-reading-settings.ts
Normal file
70
app-expo/src/core/settings/memoir-reading-settings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
/** 多故事分段时仅首段下沉首字 */
|
||||
|
||||
86
app-expo/tests/core/settings/memoir-reading-settings.test.ts
Normal file
86
app-expo/tests/core/settings/memoir-reading-settings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user