diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index afc03e68..b09a0edc 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { FlatList, Pressable, @@ -17,7 +17,8 @@ import { PencilSimple, SlidersHorizontal, } from 'phosphor-react-native' -import { MobileNote, notes, sidebarSections } from './demoData' +import { MobileNote, notes as fallbackNotes, sidebarSections } from './demoData' +import { loadDemoVaultNotes } from './mobileDemoVault' import { MobileEditorAdapter } from './MobileEditorAdapter' import { createCompactNavigationState, @@ -34,12 +35,30 @@ export function MobileApp() { const { width } = useWindowDimensions() const isTablet = width >= 820 const showsProperties = width >= 1120 - const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(notes[0].id)) + const [availableNotes, setAvailableNotes] = useState(fallbackNotes) + const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id)) const selectedNote = useMemo( - () => notes.find((note) => note.id === compactNavigation.selectedNoteId) ?? notes[0], - [compactNavigation.selectedNoteId], + () => availableNotes.find((note) => note.id === compactNavigation.selectedNoteId) ?? availableNotes[0], + [availableNotes, compactNavigation.selectedNoteId], ) + useEffect(() => { + let isActive = true + + loadDemoVaultNotes() + .then((loadedNotes) => { + if (isActive && loadedNotes.length > 0) { + setAvailableNotes(loadedNotes) + setCompactNavigation((state) => selectLoadedNote(state, loadedNotes)) + } + }) + .catch(() => {}) + + return () => { + isActive = false + } + }, []) + const selectNote = (note: MobileNote) => { setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id })) } @@ -50,7 +69,11 @@ export function MobileApp() { {isTablet ? ( - + {showsProperties ? : null} @@ -58,6 +81,7 @@ export function MobileApp() { setCompactNavigation((state) => transitionCompactNavigation(state, event))} onSelectNote={selectNote} @@ -71,12 +95,14 @@ export function MobileApp() { function CompactShell({ activePanel, note, + notes, onNavigate, onSelectNote, selectedNoteId, }: { activePanel: CompactPanel note: MobileNote + notes: MobileNote[] onNavigate: (event: CompactNavigationEvent) => void onSelectNote: (note: MobileNote) => void selectedNoteId: string @@ -112,6 +138,7 @@ function CompactShell({ return ( onNavigate({ type: 'openSidebar' })} onSelectNote={onSelectNote} @@ -154,10 +181,12 @@ function SidebarPanel({ onClose }: { onClose?: () => void }) { } function NoteListPanel({ + notes, onOpenSidebar, onSelectNote, selectedNoteId, }: { + notes: MobileNote[] onOpenSidebar?: () => void onSelectNote: (note: MobileNote) => void selectedNoteId: string @@ -205,6 +234,12 @@ function NoteListPanel({ ) } +function selectLoadedNote(state: ReturnType, loadedNotes: MobileNote[]) { + return loadedNotes.some((note) => note.id === state.selectedNoteId) + ? state + : { ...state, selectedNoteId: loadedNotes[0].id } +} + function EditorPanel({ note, onBack, diff --git a/apps/mobile/src/demoData.ts b/apps/mobile/src/demoData.ts index 50ba2ef8..b9718925 100644 --- a/apps/mobile/src/demoData.ts +++ b/apps/mobile/src/demoData.ts @@ -3,7 +3,7 @@ import { createFixtureMobileVaultRepository } from './mobileVaultRepository' export type { MobileNote } from './mobileNoteProjection' -const noteContent: MobileNoteSource[] = [ +export const demoNoteSources: MobileNoteSource[] = [ { id: 'workflow', type: 'Essay', @@ -66,9 +66,9 @@ const noteContent: MobileNoteSource[] = [ }, ] -export const notes: MobileNote[] = projectMobileNotes(noteContent) +export const notes: MobileNote[] = projectMobileNotes(demoNoteSources) -export const demoVaultRepository = createFixtureMobileVaultRepository(noteContent) +export const demoVaultRepository = createFixtureMobileVaultRepository(demoNoteSources) export const sidebarSections = [ { diff --git a/apps/mobile/src/mobileDemoVault.ts b/apps/mobile/src/mobileDemoVault.ts new file mode 100644 index 00000000..7c8567ba --- /dev/null +++ b/apps/mobile/src/mobileDemoVault.ts @@ -0,0 +1,31 @@ +import { demoNoteSources } from './demoData' +import { createMobileVaultConfig } from './mobileVaultConfig' +import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage' +import { createStoredMobileVaultRepository } from './mobileVaultRepository' +import { seedMobileVaultIfEmpty } from './mobileVaultSeed' +import type { MobileVaultFile } from './mobileVaultStorage' + +const demoVault = createDemoVaultConfig() + +export async function loadDemoVaultNotes() { + const storage = createNativeMobileVaultStorage() + await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault }) + + return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes() +} + +function demoVaultFiles(): MobileVaultFile[] { + return demoNoteSources.map((source) => ({ + path: source.filename, + content: source.content, + })) +} + +function createDemoVaultConfig() { + const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' }) + if (!result.ok) { + throw new Error(result.error) + } + + return result.config +} diff --git a/apps/mobile/src/mobileExpoVaultStorage.ts b/apps/mobile/src/mobileExpoVaultStorage.ts index f94299c0..138cd269 100644 --- a/apps/mobile/src/mobileExpoVaultStorage.ts +++ b/apps/mobile/src/mobileExpoVaultStorage.ts @@ -35,6 +35,11 @@ type VaultFileInput = { path: string } +type DirectoryInput = { + fileSystem: ExpoMobileVaultFileSystem + uri: string +} + export function createExpoMobileVaultStorage( fileSystem: ExpoMobileVaultFileSystem, ): MobileVaultStorageDriver { @@ -120,7 +125,7 @@ async function readVaultFile(input: VaultFileInput): Promise { async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) { const rootUri = vaultRootUri(fileSystem, vault) - await fileSystem.makeDirectoryAsync(rootUri, { intermediates: true }) + await ensureDirectory({ fileSystem, uri: rootUri }) return rootUri } @@ -128,10 +133,21 @@ async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: Mob async function ensureParentDirectory(input: VaultFileInput) { const parentPath = input.path.split('/').slice(0, -1).join('/') if (parentPath) { - await input.fileSystem.makeDirectoryAsync( - appendUri({ root: input.rootUri, segments: [parentPath] }), - { intermediates: true }, - ) + await ensureDirectory({ + fileSystem: input.fileSystem, + uri: appendUri({ root: input.rootUri, segments: [parentPath] }), + }) + } +} + +async function ensureDirectory(input: DirectoryInput) { + const info = await input.fileSystem.getInfoAsync(input.uri) + if (info.exists && !info.isDirectory) { + throw new Error(`Mobile vault path is not a directory: ${input.uri}`) + } + + if (!info.exists) { + await input.fileSystem.makeDirectoryAsync(input.uri, { intermediates: true }) } } diff --git a/apps/mobile/src/mobileVaultSeed.test.ts b/apps/mobile/src/mobileVaultSeed.test.ts new file mode 100644 index 00000000..5f296530 --- /dev/null +++ b/apps/mobile/src/mobileVaultSeed.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { createMobileVaultConfig } from './mobileVaultConfig' +import { seedMobileVaultIfEmpty } from './mobileVaultSeed' +import { createMemoryMobileVaultStorage } from './mobileVaultStorage' + +const vault = createVault() + +describe('mobile vault seed', () => { + it('writes starter notes when the mobile vault is empty', async () => { + const storage = createMemoryMobileVaultStorage([]) + + await expect( + seedMobileVaultIfEmpty({ + files: [{ path: 'welcome.md', content: '# Welcome' }], + storage, + vault, + }), + ).resolves.toBe('seeded') + + await expect(storage.readMarkdownFile(vault, 'welcome.md')).resolves.toBe('# Welcome') + }) + + it('keeps existing mobile vault files intact', async () => { + const storage = createMemoryMobileVaultStorage([{ path: 'existing.md', content: '# Existing' }]) + + await expect( + seedMobileVaultIfEmpty({ + files: [{ path: 'existing.md', content: '# Replacement' }], + storage, + vault, + }), + ).resolves.toBe('alreadySeeded') + + await expect(storage.readMarkdownFile(vault, 'existing.md')).resolves.toBe('# Existing') + }) +}) + +function createVault() { + const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' }) + if (!result.ok) { + throw new Error(result.error) + } + + return result.config +} diff --git a/apps/mobile/src/mobileVaultSeed.ts b/apps/mobile/src/mobileVaultSeed.ts new file mode 100644 index 00000000..4149276f --- /dev/null +++ b/apps/mobile/src/mobileVaultSeed.ts @@ -0,0 +1,23 @@ +import type { MobileVaultConfig } from './mobileVaultConfig' +import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage' + +export type MobileVaultSeedResult = 'alreadySeeded' | 'seeded' + +export async function seedMobileVaultIfEmpty({ + files, + storage, + vault, +}: { + files: MobileVaultFile[] + storage: MobileVaultStorageDriver + vault: MobileVaultConfig +}): Promise { + const existingFiles = await storage.listMarkdownFiles(vault) + if (existingFiles.length > 0) { + return 'alreadySeeded' + } + + await Promise.all(files.map((file) => storage.writeMarkdownFile(vault, file.path, file.content))) + + return 'seeded' +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index bae7034b..3ad12d03 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 2 - Mobile Shell -- Active slice: Implement Expo FileSystem vault storage +- Active slice: Seed app-local mobile demo vault - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -53,6 +53,8 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Added a mobile vault storage driver contract plus memory implementation, and connected the mobile vault repository to app-local markdown files behind that storage interface. - Added Expo FileSystem as the first app-local mobile vault storage implementation behind the storage driver contract. - Created [ADR-0111](./adr/0111-expo-file-system-vault-storage.md) to record the mobile filesystem dependency and app-local vault storage path. +- Added a mobile vault seeding boundary that writes starter Markdown files only when the app-local vault is empty. +- Wired the mobile shell to load its starter notes through the native Expo FileSystem storage driver and stored repository path, with fixture notes retained as the fallback while storage initializes. ## Next Action @@ -60,7 +62,7 @@ Continue Phase 2 with the next mobile shell slice: 1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay. 2. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists. -3. Wire the app shell to the stored repository once an initial demo vault seed path exists. +3. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists. ## Verification Log @@ -161,6 +163,12 @@ Continue Phase 2 with the next mobile shell slice: - `pnpm --filter @tolaria/mobile typecheck` passed after Expo storage adapter extraction. - CodeScene after Expo storage adapter extraction: `apps/mobile/src/mobileExpoVaultStorage.ts`, `apps/mobile/src/mobileExpoVaultStorage.test.ts`, and `apps/mobile/src/mobileNativeVaultStorage.ts` scored `10`. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after Expo storage adapter extraction. +- `pnpm --filter @tolaria/mobile test -- src/mobileVaultSeed.test.ts` passed after app-local demo vault seeding: 12 files / 40 tests. +- `pnpm --filter @tolaria/mobile test` passed after app-local demo vault seeding: 12 files / 40 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after app-local demo vault seeding. +- CodeScene after app-local demo vault seeding: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileExpoVaultStorage.ts`, `apps/mobile/src/mobileVaultSeed.ts`, and `apps/mobile/src/mobileVaultSeed.test.ts` scored `10`; `apps/mobile/src/demoData.ts` returned no scorable code and no findings. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after app-local demo vault seeding. +- `pnpm --filter @tolaria/mobile exec expo start --ios --clear --port 8087` launched on `iPad Pro 13-inch (M4)` after app-local demo vault seeding; screenshot captured at `/tmp/tolaria-mobile-seeded-ipad.png`. The app rendered via the stored repository path behind Expo Go's first-run Tools modal with no red runtime error overlay. ## Risks / Watch Items