feat: prompt for mobile note titles

This commit is contained in:
lucaronin
2026-05-05 00:20:33 +02:00
parent 1f66fbda59
commit 46aa43c1cd
8 changed files with 274 additions and 57 deletions

View File

@@ -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<Record<string, MobileEditorSaveState>>({})
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 (
<SafeAreaProvider>
@@ -123,9 +107,14 @@ export function MobileApp() {
<NoteListPanel
notes={availableNotes}
selectedNoteId={compactNavigation.selectedNoteId}
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}
/>
<EditorPanel note={selectedNote} saveState={selectedSaveState} onDraftChange={saveDraft} />
@@ -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}
/>
</SwipeSurface>
)
@@ -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({
</Pressable>
)}
/>
{createNoteFailed ? <Text style={styles.createNoteError}>Could not create note</Text> : null}
{isCreatePromptOpen ? (
<MobileNoteCreatePrompt
failed={createNoteFailed}
isCreating={isCreatingNote}
onCancel={onCancelCreateNote}
onChangeTitle={onChangeCreateNoteTitle}
onSubmit={onSubmitCreateNote}
title={createNoteTitle}
/>
) : null}
<Pressable
accessibilityLabel="Create note"
disabled={isCreatingNote}
onPress={onCreateNote}
onPress={onOpenCreateNote}
style={({ pressed }) => [
styles.composeButton,
isCreatingNote ? styles.composeButtonDisabled : null,

View File

@@ -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 (
<View style={styles.createNotePrompt}>
<TextInput
accessibilityLabel="New note title"
autoCapitalize="sentences"
editable={!isCreating}
onChangeText={onChangeTitle}
onSubmitEditing={onSubmit}
placeholder="Note title"
returnKeyType="done"
style={styles.createNoteInput}
value={title}
/>
{failed ? <Text style={styles.createNoteError}>Could not create note</Text> : null}
<View style={styles.createNoteActions}>
<PromptButton label="Cancel" onPress={onCancel} />
<PromptButton label={isCreating ? 'Creating' : 'Create'} onPress={onSubmit} disabled={isCreating} primary />
</View>
</View>
)
}
function PromptButton({
disabled,
label,
onPress,
primary,
}: {
disabled?: boolean
label: string
onPress: () => void
primary?: boolean
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.createNoteAction,
primary ? styles.createNoteActionPrimary : null,
disabled ? styles.createNoteActionDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.createNoteActionText, primary ? styles.createNoteActionTextPrimary : null]}>{label}</Text>
</Pressable>
)
}

View File

@@ -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$/, ''))

View File

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

View File

@@ -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'
}

View File

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

View File

@@ -0,0 +1,64 @@
import { useCallback, useState } from 'react'
import type { MobileNote } from './demoData'
export function useMobileNoteCreateFlow({
createNote,
onCreated,
}: {
createNote: (title: string) => Promise<MobileNote | null>
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,
}
}

View File

@@ -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