diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index b1ac7d17..bd2b6775 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -37,6 +37,8 @@ import { NamedIcon, type IconName } from './NamedIcon' import { SwipeSurface } from './SwipeSurface' import { styles } from './styles' import { colors } from './theme' +import { MobileNoteCreatePrompt } from './MobileNoteCreatePrompt' +import { useMobileNoteCreateFlow } from './useMobileNoteCreateFlow' export function MobileApp() { const { width } = useWindowDimensions() @@ -44,8 +46,6 @@ export function MobileApp() { const showsProperties = width >= 1120 const [availableNotes, setAvailableNotes] = useState(fallbackNotes) const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id)) - const [createNoteFailed, setCreateNoteFailed] = useState(false) - const [isCreatingNote, setIsCreatingNote] = useState(false) const [saveStateByNoteId, setSaveStateByNoteId] = useState>({}) const selectedNote = useMemo( () => availableNotes.find((note) => note.id === compactNavigation.selectedNoteId) ?? availableNotes[0], @@ -90,29 +90,13 @@ export function MobileApp() { setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id })) } const saveDraft = useCallback((draft: MobileEditorDraft) => autosaveQueue.enqueue(draft), [autosaveQueue]) - const createNote = useCallback(() => { - if (isCreatingNote) { - return - } - - setCreateNoteFailed(false) - setIsCreatingNote(true) - void createDemoVaultNote() - .then((note) => { - if (!note) { - return - } - - setAvailableNotes((notes) => [note, ...notes.filter((item) => item.id !== note.id)]) - setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id })) - }) - .catch(() => { - setCreateNoteFailed(true) - }) - .finally(() => { - setIsCreatingNote(false) - }) - }, [isCreatingNote]) + 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 })) + }, + }) return ( @@ -123,9 +107,14 @@ export function MobileApp() { @@ -140,9 +129,14 @@ export function MobileApp() { selectedNoteId={compactNavigation.selectedNoteId} onNavigate={(event) => setCompactNavigation((state) => transitionCompactNavigation(state, event))} onDraftChange={saveDraft} - createNoteFailed={createNoteFailed} - isCreatingNote={isCreatingNote} - onCreateNote={createNote} + createNoteFailed={createFlow.failed} + createNoteTitle={createFlow.title} + isCreatePromptOpen={createFlow.isPromptOpen} + isCreatingNote={createFlow.isCreating} + onCancelCreateNote={createFlow.cancel} + onChangeCreateNoteTitle={createFlow.setTitle} + onOpenCreateNote={createFlow.open} + onSubmitCreateNote={createFlow.submit} onSelectNote={selectNote} /> )} @@ -159,8 +153,13 @@ function CompactShell({ onNavigate, onDraftChange, createNoteFailed, + createNoteTitle, + isCreatePromptOpen, isCreatingNote, - onCreateNote, + onCancelCreateNote, + onChangeCreateNoteTitle, + onOpenCreateNote, + onSubmitCreateNote, onSelectNote, selectedNoteId, }: { @@ -169,10 +168,15 @@ function CompactShell({ notes: MobileNote[] saveState: MobileEditorSaveState createNoteFailed: boolean + createNoteTitle: string + isCreatePromptOpen: boolean isCreatingNote: boolean onNavigate: (event: CompactNavigationEvent) => void onDraftChange: (draft: MobileEditorDraft) => void - onCreateNote: () => void + onCancelCreateNote: () => void + onChangeCreateNoteTitle: (title: string) => void + onOpenCreateNote: () => void + onSubmitCreateNote: () => void onSelectNote: (note: MobileNote) => void selectedNoteId: string }) { @@ -212,10 +216,15 @@ function CompactShell({ notes={notes} selectedNoteId={selectedNoteId} createNoteFailed={createNoteFailed} + createNoteTitle={createNoteTitle} + isCreatePromptOpen={isCreatePromptOpen} isCreatingNote={isCreatingNote} - onCreateNote={onCreateNote} + onCancelCreateNote={onCancelCreateNote} + onChangeCreateNoteTitle={onChangeCreateNoteTitle} + onOpenCreateNote={onOpenCreateNote} onOpenSidebar={() => onNavigate({ type: 'openSidebar' })} onSelectNote={onSelectNote} + onSubmitCreateNote={onSubmitCreateNote} /> ) @@ -257,18 +266,28 @@ function SidebarPanel({ onClose }: { onClose?: () => void }) { function NoteListPanel({ notes, createNoteFailed, + createNoteTitle, + isCreatePromptOpen, isCreatingNote, - onCreateNote, + onCancelCreateNote, + onChangeCreateNoteTitle, + onOpenCreateNote, onOpenSidebar, onSelectNote, + onSubmitCreateNote, selectedNoteId, }: { notes: MobileNote[] createNoteFailed: boolean + createNoteTitle: string + isCreatePromptOpen: boolean isCreatingNote: boolean - onCreateNote: () => void + onCancelCreateNote: () => void + onChangeCreateNoteTitle: (title: string) => void + onOpenCreateNote: () => void onOpenSidebar?: () => void onSelectNote: (note: MobileNote) => void + onSubmitCreateNote: () => void selectedNoteId: string }) { return ( @@ -307,11 +326,20 @@ function NoteListPanel({ )} /> - {createNoteFailed ? Could not create note : null} + {isCreatePromptOpen ? ( + + ) : null} [ styles.composeButton, isCreatingNote ? styles.composeButtonDisabled : null, diff --git a/apps/mobile/src/MobileNoteCreatePrompt.tsx b/apps/mobile/src/MobileNoteCreatePrompt.tsx new file mode 100644 index 00000000..f4b88a23 --- /dev/null +++ b/apps/mobile/src/MobileNoteCreatePrompt.tsx @@ -0,0 +1,66 @@ +import { Pressable, Text, TextInput, View } from 'react-native' +import { styles } from './styles' + +export function MobileNoteCreatePrompt({ + failed, + isCreating, + onCancel, + onChangeTitle, + onSubmit, + title, +}: { + failed: boolean + isCreating: boolean + onCancel: () => void + onChangeTitle: (title: string) => void + onSubmit: () => void + title: string +}) { + return ( + + + {failed ? Could not create note : null} + + + + + + ) +} + +function PromptButton({ + disabled, + label, + onPress, + primary, +}: { + disabled?: boolean + label: string + onPress: () => void + primary?: boolean +}) { + return ( + [ + styles.createNoteAction, + primary ? styles.createNoteActionPrimary : null, + disabled ? styles.createNoteActionDisabled : null, + pressed ? styles.pressed : null, + ]} + > + {label} + + ) +} diff --git a/apps/mobile/src/mobileDemoVault.ts b/apps/mobile/src/mobileDemoVault.ts index 9db3ac10..8b1d0574 100644 --- a/apps/mobile/src/mobileDemoVault.ts +++ b/apps/mobile/src/mobileDemoVault.ts @@ -25,9 +25,9 @@ export function saveDemoVaultDraft(draft: MobileEditorDraft) { }) } -export async function createDemoVaultNote() { +export async function createDemoVaultNote({ title }: { title?: string } = {}) { const storage = createNativeMobileVaultStorage() - const file = createMobileNoteFile() + const file = createMobileNoteFile({ title }) await storage.writeMarkdownFile(demoVault, file.path, file.content) return createStoredMobileVaultRepository({ storage, vault: demoVault }).readNote(file.path.replace(/\.md$/, '')) diff --git a/apps/mobile/src/mobileNoteCreate.test.ts b/apps/mobile/src/mobileNoteCreate.test.ts index 285e4669..d9139694 100644 --- a/apps/mobile/src/mobileNoteCreate.test.ts +++ b/apps/mobile/src/mobileNoteCreate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createMobileNoteFile } from './mobileNoteCreate' +import { createMobileNoteFile, normalizeMobileNoteCreateTitle } from './mobileNoteCreate' describe('mobile note create', () => { it('creates a deterministic markdown file from the current time', () => { @@ -31,4 +31,9 @@ describe('mobile note create', () => { expect(file.content).toContain('title: Meeting notes') expect(file.content).toContain('# Meeting notes') }) + + it('normalizes blank and padded create titles', () => { + expect(normalizeMobileNoteCreateTitle(' Meeting notes ')).toBe('Meeting notes') + expect(normalizeMobileNoteCreateTitle(' ')).toBe('Untitled') + }) }) diff --git a/apps/mobile/src/mobileNoteCreate.ts b/apps/mobile/src/mobileNoteCreate.ts index c1ad7b07..743cce81 100644 --- a/apps/mobile/src/mobileNoteCreate.ts +++ b/apps/mobile/src/mobileNoteCreate.ts @@ -8,18 +8,23 @@ export function createMobileNoteFile({ title?: string } = {}): MobileVaultFile { const id = `note-${now.getTime().toString(36)}` + const displayTitle = normalizeMobileNoteCreateTitle(title) return { path: `${id}.md`, content: [ '---', - `title: ${title}`, + `title: ${displayTitle}`, 'type: Note', `created: ${now.toISOString()}`, '---', '', - `# ${title}`, + `# ${displayTitle}`, '', ].join('\n'), } } + +export function normalizeMobileNoteCreateTitle(title: string) { + return title.trim() || 'Untitled' +} diff --git a/apps/mobile/src/styles/noteCreateStyles.ts b/apps/mobile/src/styles/noteCreateStyles.ts index f9303dba..7e90e62b 100644 --- a/apps/mobile/src/styles/noteCreateStyles.ts +++ b/apps/mobile/src/styles/noteCreateStyles.ts @@ -5,17 +5,60 @@ export const noteCreateStyles = StyleSheet.create({ composeButtonDisabled: { opacity: 0.55, }, - createNoteError: { - position: 'absolute', - right: spacing.xl, - bottom: spacing.xl + 76, - maxWidth: 220, - borderRadius: 14, + createNoteAction: { + minWidth: 82, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 16, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, + backgroundColor: colors.primarySoft, + }, + createNoteActionDisabled: { + opacity: 0.55, + }, + createNoteActionPrimary: { + backgroundColor: colors.primary, + }, + createNoteActionText: { + color: colors.primary, + fontSize: 14, + fontWeight: '700', + }, + createNoteActionTextPrimary: { + color: colors.canvas, + }, + createNoteActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + }, + createNoteError: { color: colors.textSoft, - backgroundColor: colors.chipRed, fontSize: 13, fontWeight: '600', }, + createNoteInput: { + minHeight: 44, + borderColor: colors.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 14, + paddingHorizontal: spacing.md, + color: colors.text, + backgroundColor: colors.canvas, + fontSize: 16, + fontWeight: '600', + }, + createNotePrompt: { + position: 'absolute', + right: spacing.xl, + bottom: spacing.xl + 76, + width: 280, + gap: spacing.sm, + borderColor: colors.border, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 18, + padding: spacing.md, + backgroundColor: colors.canvas, + }, }) diff --git a/apps/mobile/src/useMobileNoteCreateFlow.ts b/apps/mobile/src/useMobileNoteCreateFlow.ts new file mode 100644 index 00000000..99af1715 --- /dev/null +++ b/apps/mobile/src/useMobileNoteCreateFlow.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from 'react' +import type { MobileNote } from './demoData' + +export function useMobileNoteCreateFlow({ + createNote, + onCreated, +}: { + createNote: (title: string) => Promise + onCreated: (note: MobileNote) => void +}) { + const [failed, setFailed] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const [isPromptOpen, setIsPromptOpen] = useState(false) + const [title, setTitle] = useState('') + + const cancel = useCallback(() => { + if (isCreating) { + return + } + + setFailed(false) + setIsPromptOpen(false) + setTitle('') + }, [isCreating]) + + const open = useCallback(() => { + setFailed(false) + setIsPromptOpen(true) + }, []) + + const submit = useCallback(() => { + if (isCreating) { + return + } + + setFailed(false) + setIsCreating(true) + void createNote(title) + .then((note) => { + if (note) { + setIsPromptOpen(false) + setTitle('') + onCreated(note) + } + }) + .catch(() => { + setFailed(true) + }) + .finally(() => { + setIsCreating(false) + }) + }, [createNote, isCreating, onCreated, title]) + + return { + cancel, + failed, + isCreating, + isPromptOpen, + open, + setTitle, + submit, + title, + } +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 7666b3a2..dbea3149 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -1,14 +1,14 @@ # Mobile Progress -Last updated: 2026-05-04 +Last updated: 2026-05-05 This file is the resumable working log for Tolaria mobile. The strategy and roadmap live in [MOBILE_STRATEGY.md](./MOBILE_STRATEGY.md); this file records the current execution state. ## Current State - Branch: `codex/mobile` -- Active phase: Phase 2 - Mobile Shell -- Active slice: Add create-note UX state +- Active phase: Phase 3 - App-Managed Vault Storage +- Active slice: Start durable app-managed vault state - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -64,14 +64,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Refreshed the in-memory mobile note projection after the latest successful editor save so the note list, editor source, properties words, and snippets update from canonical Markdown. - 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. ## Next Action -Continue Phase 2 with the next mobile shell slice: +Continue Phase 3 with app-managed vault storage hardening: -1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay. -2. Add a focused simulator interaction path for editor typing/autosave once Expo Go's overlay no longer blocks clean screenshots. -3. Add an eventual title-entry flow for new mobile notes instead of creating `Untitled` immediately. +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. ## Verification Log @@ -215,6 +216,11 @@ Continue Phase 2 with the next mobile shell slice: - CodeScene after create-note UX state: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/styles/noteListStyles.ts`, and `apps/mobile/src/styles/noteCreateStyles.ts` scored `10`. - `pnpm --filter @tolaria/mobile test` passed after create-note UX state: 17 files / 53 tests. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after create-note UX state. +- `pnpm --filter @tolaria/mobile test -- src/mobileNoteCreate.test.ts` passed after title-entry note creation: 17 files / 54 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after title-entry note creation. +- 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. ## Risks / Watch Items