diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index e456c51b..cf2b1101 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -18,26 +18,28 @@ import { SlidersHorizontal, } from 'phosphor-react-native' import { MobileNote, notes, sidebarSections } from './demoData' +import { + createCompactNavigationState, + transitionCompactNavigation, + type CompactNavigationEvent, + type CompactPanel, +} from './compactNavigation' import { NamedIcon, type IconName } from './NamedIcon' import { styles } from './styles' import { colors } from './theme' -type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties' - export function MobileApp() { const { width } = useWindowDimensions() const isTablet = width >= 820 const showsProperties = width >= 1120 - const [selectedNoteId, setSelectedNoteId] = useState(notes[0].id) - const [compactPanel, setCompactPanel] = useState('list') + const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(notes[0].id)) const selectedNote = useMemo( - () => notes.find((note) => note.id === selectedNoteId) ?? notes[0], - [selectedNoteId], + () => notes.find((note) => note.id === compactNavigation.selectedNoteId) ?? notes[0], + [compactNavigation.selectedNoteId], ) const selectNote = (note: MobileNote) => { - setSelectedNoteId(note.id) - setCompactPanel('note') + setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id })) } return ( @@ -46,16 +48,16 @@ export function MobileApp() { {isTablet ? ( - + {showsProperties ? : null} ) : ( setCompactNavigation((state) => transitionCompactNavigation(state, event))} onSelectNote={selectNote} /> )} @@ -67,38 +69,38 @@ export function MobileApp() { function CompactShell({ activePanel, note, - onPanelChange, + onNavigate, onSelectNote, selectedNoteId, }: { activePanel: CompactPanel note: MobileNote - onPanelChange: (panel: CompactPanel) => void + onNavigate: (event: CompactNavigationEvent) => void onSelectNote: (note: MobileNote) => void selectedNoteId: string }) { if (activePanel === 'sidebar') { - return onPanelChange('list')} /> + return onNavigate({ type: 'closeSidebar' })} /> } if (activePanel === 'note') { return ( onPanelChange('list')} - onOpenProperties={() => onPanelChange('properties')} + onBack={() => onNavigate({ type: 'backToList' })} + onOpenProperties={() => onNavigate({ type: 'openProperties' })} /> ) } if (activePanel === 'properties') { - return onPanelChange('note')} /> + return onNavigate({ type: 'closeProperties' })} /> } return ( onPanelChange('sidebar')} + onOpenSidebar={() => onNavigate({ type: 'openSidebar' })} onSelectNote={onSelectNote} /> ) diff --git a/apps/mobile/src/compactNavigation.test.ts b/apps/mobile/src/compactNavigation.test.ts new file mode 100644 index 00000000..ec3b5261 --- /dev/null +++ b/apps/mobile/src/compactNavigation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + createCompactNavigationState, + transitionCompactNavigation, + type CompactNavigationState, +} from './compactNavigation' + +describe('compact mobile navigation', () => { + it('starts on the note list with the first note selected', () => { + expect(createCompactNavigationState('workflow')).toEqual({ + panel: 'list', + selectedNoteId: 'workflow', + }) + }) + + it('opens a selected note from the list', () => { + const next = transitionCompactNavigation(createCompactNavigationState('workflow'), { + type: 'selectNote', + noteId: 'release', + }) + + expect(next).toEqual({ + panel: 'note', + selectedNoteId: 'release', + }) + }) + + it('returns from sidebar and note surfaces to the list', () => { + expect(panelAfter({ type: 'openSidebar' })).toBe('sidebar') + expect(panelAfter({ type: 'closeSidebar' }, 'sidebar')).toBe('list') + expect(panelAfter({ type: 'backToList' }, 'note')).toBe('list') + }) + + it('opens and closes note properties without changing the selected note', () => { + const noteState: CompactNavigationState = { + panel: 'note', + selectedNoteId: 'workflow', + } + + const propertiesState = transitionCompactNavigation(noteState, { type: 'openProperties' }) + const closedState = transitionCompactNavigation(propertiesState, { type: 'closeProperties' }) + + expect(propertiesState).toEqual({ + panel: 'properties', + selectedNoteId: 'workflow', + }) + expect(closedState).toEqual({ + panel: 'note', + selectedNoteId: 'workflow', + }) + }) +}) + +function panelAfter( + event: Parameters[1], + panel: CompactNavigationState['panel'] = 'list', +) { + const state = transitionCompactNavigation({ panel, selectedNoteId: 'workflow' }, event) + return state.panel +} diff --git a/apps/mobile/src/compactNavigation.ts b/apps/mobile/src/compactNavigation.ts new file mode 100644 index 00000000..08004190 --- /dev/null +++ b/apps/mobile/src/compactNavigation.ts @@ -0,0 +1,55 @@ +export type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties' + +export type CompactNavigationState = { + panel: CompactPanel + selectedNoteId: string +} + +export type CompactNavigationEvent = + | { type: 'backToList' } + | { type: 'closeProperties' } + | { type: 'closeSidebar' } + | { type: 'openProperties' } + | { type: 'openSidebar' } + | { type: 'selectNote'; noteId: string } + +export function createCompactNavigationState(initialNoteId: string): CompactNavigationState { + return { + panel: 'list', + selectedNoteId: initialNoteId, + } +} + +export function transitionCompactNavigation( + state: CompactNavigationState, + event: CompactNavigationEvent, +): CompactNavigationState { + if (event.type === 'selectNote') { + return { panel: 'note', selectedNoteId: event.noteId } + } + + return { + ...state, + panel: nextPanel(state.panel, event), + } +} + +function nextPanel(currentPanel: CompactPanel, event: Exclude) { + if (event.type === 'openSidebar') { + return 'sidebar' + } + + if (event.type === 'closeSidebar' || event.type === 'backToList') { + return 'list' + } + + if (event.type === 'openProperties') { + return 'properties' + } + + if (event.type === 'closeProperties') { + return 'note' + } + + return currentPanel +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 48046fcc..4a1152f7 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: Scaffold iPad-first Expo shell with fixture data +- Active slice: Prepare compact navigation for gesture-driven phone shell - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -34,15 +34,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Added the first iPad-first/phone-ready shell using fixture notes, shared Markdown title/snippet helpers, Phosphor React Native icons, and responsive panels for sidebar, note list, editor, and properties. - Split mobile styles into small panel-specific StyleSheet modules so new mobile code starts at CodeScene `10.0`. - Pinned Expo runtime dependencies to the versions expected by Expo 55 after the initial generated versions failed iOS bundle export (`react-native@0.83.6`, `react@19.2.0`, matching safe-area/svg versions). +- Extracted compact phone navigation into a pure tested state machine so future swipe gestures can dispatch explicit events instead of mutating panels ad hoc. ## Next Action Continue Phase 2 with the next mobile shell slice: 1. Resolve the local CoreSimulator hang so `pnpm mobile:ios` can launch the app in an iPad simulator. -2. Add a navigation-state test around the compact panel flow before introducing gestures. -3. Add native gesture support for phone/tablet panel transitions. -4. Start the storage/auth abstraction skeleton only after the shell has a reliable simulator loop. +2. Add native gesture support for phone/tablet panel transitions once the simulator path is usable. +3. Start the storage/auth abstraction skeleton after the shell has a reliable simulator loop. ## Verification Log @@ -81,6 +81,10 @@ Continue Phase 2 with the next mobile shell slice: - CodeScene pre-commit safeguard passed for the current change set. - Codacy MCP can read repository analysis and security items for `refactoringhq/tolaria`; local Codacy CLI analysis is currently blocked because the Codacy CLI binary is not installed in this environment. - iOS simulator validation is blocked locally: Xcode is installed (`Xcode 26.3`), but `xcrun simctl list ...` hangs indefinitely even after opening `/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app`. +- `pnpm --filter @tolaria/mobile test` passed after compact navigation extraction: 2 files / 6 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after compact navigation extraction. +- CodeScene after compact navigation extraction: `apps/mobile/src/compactNavigation.ts`, `apps/mobile/src/compactNavigation.test.ts`, and the touched `apps/mobile/src/MobileApp.tsx` scored `10`. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after compact navigation extraction. ## Risks / Watch Items