diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index bd2b6775..89994221 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -39,11 +39,15 @@ import { styles } from './styles' import { colors } from './theme' import { MobileNoteCreatePrompt } from './MobileNoteCreatePrompt' import { useMobileNoteCreateFlow } from './useMobileNoteCreateFlow' +import { createNativeMobileAppStateStorage } from './mobileNativeAppStateStorage' + +const activeMobileVaultId = 'personal' export function MobileApp() { const { width } = useWindowDimensions() const isTablet = width >= 820 const showsProperties = width >= 1120 + const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), []) const [availableNotes, setAvailableNotes] = useState(fallbackNotes) const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id)) const [saveStateByNoteId, setSaveStateByNoteId] = useState>({}) @@ -70,11 +74,11 @@ export function MobileApp() { useEffect(() => { let isActive = true - loadDemoVaultNotes() - .then((loadedNotes) => { + Promise.all([loadDemoVaultNotes(), appStateStorage.load(activeMobileVaultId)]) + .then(([loadedNotes, appState]) => { if (isActive && loadedNotes.length > 0) { setAvailableNotes(loadedNotes) - setCompactNavigation((state) => selectLoadedNote(state, loadedNotes)) + setCompactNavigation((state) => selectLoadedNote(state, loadedNotes, appState.selectedNoteId)) } }) .catch(() => {}) @@ -82,19 +86,21 @@ export function MobileApp() { return () => { isActive = false } - }, []) + }, [appStateStorage]) useEffect(() => () => autosaveQueue.cancelAll(), [autosaveQueue]) - const selectNote = (note: MobileNote) => { - setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id })) - } + const selectNoteId = useCallback((noteId: string) => { + setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId })) + void appStateStorage.save({ activeVaultId: activeMobileVaultId, selectedNoteId: noteId }).catch(() => {}) + }, [appStateStorage]) + const selectNote = useCallback((note: MobileNote) => selectNoteId(note.id), [selectNoteId]) const saveDraft = useCallback((draft: MobileEditorDraft) => autosaveQueue.enqueue(draft), [autosaveQueue]) const createFlow = useMobileNoteCreateFlow({ createNote: (title) => createDemoVaultNote({ title }), onCreated: (note) => { setAvailableNotes((notes) => [note, ...notes.filter((item) => item.id !== note.id)]) - setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id })) + selectNoteId(note.id) }, }) @@ -352,7 +358,15 @@ function NoteListPanel({ ) } -function selectLoadedNote(state: ReturnType, loadedNotes: MobileNote[]) { +function selectLoadedNote( + state: ReturnType, + loadedNotes: MobileNote[], + preferredNoteId: string | null, +) { + if (preferredNoteId && loadedNotes.some((note) => note.id === preferredNoteId)) { + return { ...state, selectedNoteId: preferredNoteId } + } + return loadedNotes.some((note) => note.id === state.selectedNoteId) ? state : { ...state, selectedNoteId: loadedNotes[0].id } diff --git a/apps/mobile/src/mobileAppStateStorage.test.ts b/apps/mobile/src/mobileAppStateStorage.test.ts new file mode 100644 index 00000000..df771da7 --- /dev/null +++ b/apps/mobile/src/mobileAppStateStorage.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + createMobileAppStateStorage, + type MobileAppStateFileSystem, +} from './mobileAppStateStorage' + +describe('mobile app state storage', () => { + it('returns default state when no app state file exists', async () => { + const storage = createMobileAppStateStorage(createMemoryAppStateFileSystem()) + + await expect(storage.load('personal')).resolves.toEqual({ + activeVaultId: 'personal', + selectedNoteId: null, + }) + }) + + it('persists and restores selected note id for the active vault', async () => { + const fileSystem = createMemoryAppStateFileSystem() + const storage = createMobileAppStateStorage(fileSystem) + + await storage.save({ activeVaultId: 'personal', selectedNoteId: 'workflow' }) + + await expect(storage.load('personal')).resolves.toEqual({ + activeVaultId: 'personal', + selectedNoteId: 'workflow', + }) + }) + + it('ignores corrupt or mismatched state files', async () => { + const fileSystem = createMemoryAppStateFileSystem({ + 'file:///docs/state/app-state.json': '{"activeVaultId":"other","selectedNoteId":"workflow"}', + }) + const storage = createMobileAppStateStorage(fileSystem) + + await expect(storage.load('personal')).resolves.toEqual({ + activeVaultId: 'personal', + selectedNoteId: null, + }) + }) +}) + +function createMemoryAppStateFileSystem(files: Record = {}): MobileAppStateFileSystem { + const fileByUri = new Map(Object.entries(files)) + const directoryUris = new Set(['file:///docs']) + + return { + documentDirectory: 'file:///docs/', + getInfoAsync: async (uri) => ({ + exists: fileByUri.has(uri) || directoryUris.has(uri), + isDirectory: directoryUris.has(uri), + }), + makeDirectoryAsync: async (uri) => { + directoryUris.add(uri) + }, + readAsStringAsync: async (uri) => fileByUri.get(uri) ?? '', + writeAsStringAsync: async (uri, content) => { + fileByUri.set(uri, content) + }, + } +} diff --git a/apps/mobile/src/mobileAppStateStorage.ts b/apps/mobile/src/mobileAppStateStorage.ts new file mode 100644 index 00000000..0fb5c6b3 --- /dev/null +++ b/apps/mobile/src/mobileAppStateStorage.ts @@ -0,0 +1,126 @@ +export type MobileAppState = { + activeVaultId: string + selectedNoteId: string | null +} + +export type MobileAppStateFileInfo = { + exists: boolean + isDirectory?: boolean +} + +export type MobileAppStateFileSystem = { + documentDirectory: string | null + getInfoAsync: (uri: string) => Promise + makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise + readAsStringAsync: (uri: string) => Promise + writeAsStringAsync: (uri: string, content: string) => Promise +} + +export type MobileAppStateStorage = { + load: (activeVaultId: string) => Promise + save: (state: MobileAppState) => Promise +} + +export function createMobileAppStateStorage( + fileSystem: MobileAppStateFileSystem, +): MobileAppStateStorage { + return { + load: async (activeVaultId) => loadMobileAppState({ activeVaultId, fileSystem }), + save: async (state) => saveMobileAppState({ fileSystem, state }), + } +} + +async function loadMobileAppState({ + activeVaultId, + fileSystem, +}: { + activeVaultId: string + fileSystem: MobileAppStateFileSystem +}) { + const fileUri = appStateFileUri(fileSystem) + const info = await fileSystem.getInfoAsync(fileUri) + if (!info.exists || info.isDirectory) { + return defaultMobileAppState(activeVaultId) + } + + return parseMobileAppState({ + activeVaultId, + content: await fileSystem.readAsStringAsync(fileUri), + }) +} + +async function saveMobileAppState({ + fileSystem, + state, +}: { + fileSystem: MobileAppStateFileSystem + state: MobileAppState +}) { + const rootUri = appStateRootUri(fileSystem) + await ensureDirectory({ fileSystem, uri: rootUri }) + await fileSystem.writeAsStringAsync(appStateFileUri(fileSystem), JSON.stringify(state)) +} + +function parseMobileAppState({ + activeVaultId, + content, +}: { + activeVaultId: string + content: string +}) { + try { + return coerceMobileAppState({ activeVaultId, value: JSON.parse(content) }) + } catch { + return defaultMobileAppState(activeVaultId) + } +} + +function coerceMobileAppState({ + activeVaultId, + value, +}: { + activeVaultId: string + value: unknown +}): MobileAppState { + if (!isStateRecord(value) || value.activeVaultId !== activeVaultId) { + return defaultMobileAppState(activeVaultId) + } + + return { + activeVaultId, + selectedNoteId: typeof value.selectedNoteId === 'string' ? value.selectedNoteId : null, + } +} + +function defaultMobileAppState(activeVaultId: string): MobileAppState { + return { activeVaultId, selectedNoteId: null } +} + +async function ensureDirectory({ + fileSystem, + uri, +}: { + fileSystem: MobileAppStateFileSystem + uri: string +}) { + const info = await fileSystem.getInfoAsync(uri) + if (!info.exists) { + await fileSystem.makeDirectoryAsync(uri, { intermediates: true }) + } +} + +function appStateFileUri(fileSystem: MobileAppStateFileSystem) { + return `${appStateRootUri(fileSystem)}/app-state.json` +} + +function appStateRootUri(fileSystem: MobileAppStateFileSystem) { + if (!fileSystem.documentDirectory) { + throw new Error('Expo FileSystem documentDirectory is unavailable') + } + + return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state` +} + +function isStateRecord(value: unknown): value is { activeVaultId?: unknown; selectedNoteId?: unknown } { + return typeof value === 'object' && value !== null +} diff --git a/apps/mobile/src/mobileNativeAppStateStorage.ts b/apps/mobile/src/mobileNativeAppStateStorage.ts new file mode 100644 index 00000000..17ea4469 --- /dev/null +++ b/apps/mobile/src/mobileNativeAppStateStorage.ts @@ -0,0 +1,6 @@ +import * as ExpoFileSystem from 'expo-file-system/legacy' +import { createMobileAppStateStorage } from './mobileAppStateStorage' + +export function createNativeMobileAppStateStorage() { + return createMobileAppStateStorage(ExpoFileSystem) +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index dbea3149..21740dfb 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -65,14 +65,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Added the first app-local mobile note creation path and wired the compose button to write a new Markdown note, prepend it to the current list, and select it through the shared compact navigation reducer. - Added create-note UX state so the compose button disables during app-local creation and displays a compact failure message if storage creation fails. - Added a title-entry prompt for mobile note creation so new notes are named before they are written to app-local storage. +- Added app-managed mobile state storage for the active vault and last selected note, then restored and persisted note selection across launches. ## Next Action Continue Phase 3 with app-managed vault storage hardening: -1. Add local saved state for active vault and last selected note, then restore it on launch. -2. Add delete/archive support for local app-managed notes. -3. Add a focused simulator interaction path for create/open/edit/autosave once Expo Go's overlay no longer blocks clean screenshots. +1. Add delete/archive support for local app-managed notes. +2. Add a focused simulator interaction path for create/open/edit/autosave once Expo Go's overlay no longer blocks clean screenshots. +3. Add app-local vault metadata beyond the hardcoded demo vault id. ## Verification Log @@ -221,6 +222,11 @@ Continue Phase 3 with app-managed vault storage hardening: - CodeScene after title-entry note creation: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileNoteCreatePrompt.tsx`, `apps/mobile/src/useMobileNoteCreateFlow.ts`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileNoteCreate.test.ts`, and `apps/mobile/src/styles/noteCreateStyles.ts` scored `10`. - `pnpm --filter @tolaria/mobile test` passed after title-entry note creation: 17 files / 54 tests. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after title-entry note creation. +- `pnpm --filter @tolaria/mobile test -- src/mobileAppStateStorage.test.ts src/mobileNoteCreate.test.ts` passed after saved app state storage: 18 files / 57 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after saved app state storage. +- CodeScene after saved app state storage: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileAppStateStorage.ts`, `apps/mobile/src/mobileAppStateStorage.test.ts`, and `apps/mobile/src/mobileNativeAppStateStorage.ts` scored `10`. +- `pnpm --filter @tolaria/mobile test` passed after saved app state storage: 18 files / 57 tests. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after saved app state storage. ## Risks / Watch Items