feat: prompt for mobile note titles
This commit is contained in:
@@ -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,
|
||||
|
||||
66
apps/mobile/src/MobileNoteCreatePrompt.tsx
Normal file
66
apps/mobile/src/MobileNoteCreatePrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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$/, ''))
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
64
apps/mobile/src/useMobileNoteCreateFlow.ts
Normal file
64
apps/mobile/src/useMobileNoteCreateFlow.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user