diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 5a66a2d..c8a9081 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -27,9 +27,9 @@ [ "expo-splash-screen", { - "backgroundColor": "#faf9f5", + "backgroundColor": "#f7f5f0", "dark": { - "backgroundColor": "#121110" + "backgroundColor": "#000000" }, "image": "./assets/images/splash-icon.png", "imageWidth": 76 @@ -42,6 +42,12 @@ { "photosPermission": "DailyNote adds the photos you pick to the note you are writing. They are copied into the app and stay on this device." } + ], + [ + "expo-local-authentication", + { + "faceIDPermission": "DailyNote uses Face ID to open notes you have locked." + } ] ], "experiments": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b29b575..5e26c2d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,7 @@ "@dailynote/core": "*", "@expo-google-fonts/newsreader": "^0.4.1", "@expo/ui": "~57.0.8", + "@expo/vector-icons": "^15.0.2", "expo": "~57.0.8", "expo-constants": "~57.0.7", "expo-crypto": "~57.0.1", @@ -26,6 +27,7 @@ "expo-image": "~57.0.1", "expo-image-picker": "~57.0.7", "expo-linking": "~57.0.4", + "expo-local-authentication": "~57.0.2", "expo-router": "~57.0.9", "expo-splash-screen": "~57.0.5", "expo-sqlite": "~57.0.1", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..26db4dc --- /dev/null +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -0,0 +1,49 @@ +import { Tabs } from 'expo-router'; + +import { Icon } from '@/components/icon'; +import { useTheme } from '@/theme'; + +/** + * Notes and To-dos, the reference's two tabs. + * + * To-dos is not a second store: a to-do is a note that has been promoted to a + * commitment, so the tab is a view over the same table rather than a parallel + * feature with its own data. + */ +export default function TabsLayout() { + const theme = useTheme(); + + return ( + + , + }} + /> + , + }} + /> + + ); +} diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..745bd4d --- /dev/null +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -0,0 +1,414 @@ +import { + ALL_NOTES, + DEFAULT_NOTEBOOK, + searchNotes, + type Note, + type Notebook, +} from '@dailynote/core'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import ReanimatedSwipeable, { + type SwipeableMethods, +} from 'react-native-gesture-handler/ReanimatedSwipeable'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Icon } from '@/components/icon'; +import { shortDate } from '@/lib/format'; +import { haptics } from '@/lib/haptics'; +import * as motion from '@/lib/motion'; +import { AnimatedPressable } from '@/lib/motion'; +import { useNotebooks } from '@/store/notebooks-store'; +import { useNotes } from '@/store/notes-store'; +import { useTheme } from '@/theme'; + +/** + * The notes list, and the app's front door. + * + * The composer used to be the launch screen. The reference puts the library + * first and reaches capture through the floating button, which is the shape + * users of every other notes app already expect -- so opening the app now + * shows what you have rather than a blank page. + */ +export default function NotesList() { + const theme = useTheme(); + const router = useRouter(); + const { notes, loading } = useNotes(); + const { notebooks } = useNotebooks(); + + const [query, setQuery] = useState(''); + const [searching, setSearching] = useState(false); + + // The selected notebook lives in the route rather than in state: the drawer + // picks one and dismisses back to here, and having both a param and a + // useState would mean two sources of truth to keep in step. + const { filter: requested } = useLocalSearchParams<{ filter?: string }>(); + const filter = requested ?? ALL_NOTES; + const setFilter = useCallback( + (next: string) => router.setParams({ filter: next }), + [router], + ); + + // Only root notes are listed; updates belong inside their thread. Locked + // notes stay out of every list until the lock is satisfied. + const rootNotes = useMemo( + () => notes.filter((note) => note.kind === 'note' && !note.locked), + [notes], + ); + + const inNotebook = useMemo(() => { + if (filter === ALL_NOTES) return rootNotes; + if (filter === DEFAULT_NOTEBOOK) return rootNotes.filter((note) => note.notebookId === null); + return rootNotes.filter((note) => note.notebookId === filter); + }, [rootNotes, filter]); + + const results = useMemo(() => searchNotes(inNotebook, query), [inNotebook, query]); + + const openSearch = useCallback(() => { + setSearching((open) => { + if (open) setQuery(''); + return !open; + }); + }, []); + + const fabPressed = useSharedValue(0); + const fabStyle = useAnimatedStyle(() => ({ + transform: [{ scale: 1 - 0.08 * fabPressed.value }], + })); + + return ( + + + + router.push('/notebooks')} /> + + + result.note.id} + keyboardDismissMode="on-drag" + contentContainerClassName="pb-32" + itemLayoutAnimation={motion.layout} + ListHeaderComponent={ + + } + ListEmptyComponent={loading ? null : } + renderItem={({ item, index }) => ( + + )} + /> + + { + haptics.tap(); + router.push('/note/new'); + }} + onPressIn={() => { + fabPressed.value = withSpring(1, motion.SPRING); + }} + onPressOut={() => { + fabPressed.value = withSpring(0, motion.SPRING); + }} + style={fabStyle} + className="absolute bottom-6 right-5 h-16 w-16 items-center justify-center rounded-full bg-accent shadow-lg dark:bg-accent-dark" + > + + + + ); +} + +function ListHeader({ + count, + notebooks, + filter, + onFilter, + searching, + query, + onQuery, +}: { + count: number; + notebooks: Notebook[]; + filter: string; + onFilter: (next: string) => void; + searching: boolean; + query: string; + onQuery: (next: string) => void; +}) { + const theme = useTheme(); + const router = useRouter(); + + return ( + + + Notes + + + {count === 1 ? '1 note' : `${count} notes`} + + + {searching ? ( + + + + ) : null} + + {/* The notebook filter rail. The leading button opens the drawer; the + chips after it are the same set, one tap away. */} + + router.push('/notebooks')} + className="h-11 w-12 items-center justify-center rounded-xl bg-card active:opacity-60 dark:bg-card-dark" + > + + + + onFilter(ALL_NOTES)} /> + {notebooks.map((notebook) => ( + onFilter(notebook.id)} + /> + ))} + onFilter(DEFAULT_NOTEBOOK)} + /> + + + ); +} + +function Chip({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +/** + * One row in the grouped card. + * + * The rows share a single card surface rather than each being its own tile: + * only the first and last round their corners, and a hairline inset from the + * left divides the ones between. + */ +function NoteRow({ + note, + index, + first, + last, +}: { + note: Note; + index: number; + first: boolean; + last: boolean; +}) { + const theme = useTheme(); + const router = useRouter(); + const { softDelete, togglePin } = useNotes(); + const swipeable = useRef(null); + + // The preview is the body minus its title line, flattened to one line. The + // body is already the document's plain-text projection; only the bullet + // marker it keeps for `deriveTitle` has to come back off, since a row full + // of dashes reads as noise at this size. + const preview = useMemo( + () => + note.body + .split('\n') + .slice(1) + .map((line) => line.replace(/^- /, '')) + .join(' ') + .replace(/\s+/g, ' ') + .trim(), + [note.body], + ); + + const pressed = useSharedValue(0); + const pressStyle = useAnimatedStyle(() => ({ + opacity: 1 - 0.35 * pressed.value, + })); + + return ( + + ( + // Muted, not red: this is a soft delete into Recently deleted, and + // alarm colours would make tidying up feel like danger. + { + haptics.tap(); + swipeable.current?.close(); + void softDelete(note.id); + }} + className="ml-2 w-20 items-center justify-center rounded-xl bg-elevated active:opacity-60 dark:bg-elevated-dark" + > + + + )} + > + router.push({ pathname: '/note/[id]', params: { id: note.id } })} + onLongPress={() => { + haptics.tap(); + void togglePin(note.id); + }} + onPressIn={() => { + pressed.value = withSpring(1, motion.SPRING); + }} + onPressOut={() => { + pressed.value = withSpring(0, motion.SPRING); + }} + style={pressStyle} + className={[ + 'bg-card px-4 dark:bg-card-dark', + first ? 'rounded-t-2xl pt-4' : 'pt-3.5', + last ? 'rounded-b-2xl pb-4' : 'pb-3.5', + ].join(' ')} + > + + {note.pinnedAt !== null ? ( + + ) : null} + {note.nextReviewAt !== null ? ( + + ) : null} + + {note.title ?? 'Untitled'} + + + + {/* Date and preview share one line, as in the reference: the date + is the anchor you scan, the preview is what's left over. */} + + {shortDate(note.createdAt)} + {preview.length > 0 ? ` ${preview}` : ''} + + + + + {!last ? : null} + + ); +} + +function Empty({ query }: { query: string }) { + return ( + + + {query.length > 0 ? 'Nothing matches.' : 'No notes yet.'} + + {query.length === 0 ? ( + + Tap + to write your first one. + + ) : null} + + ); +} + +function IconButton({ + name, + label, + onPress, + tint, +}: { + name: Parameters[0]['name']; + label: string; + onPress: () => void; + tint?: string; +}) { + const theme = useTheme(); + return ( + + + + ); +} diff --git a/apps/mobile/src/app/(tabs)/todos.tsx b/apps/mobile/src/app/(tabs)/todos.tsx new file mode 100644 index 0000000..4e11c93 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/todos.tsx @@ -0,0 +1,147 @@ +import type { Note } from '@dailynote/core'; +import { useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Icon } from '@/components/icon'; +import { comesBack } from '@/lib/format'; +import { haptics } from '@/lib/haptics'; +import * as motion from '@/lib/motion'; +import { useNotes } from '@/store/notes-store'; +import { useTheme } from '@/theme'; + +/** + * To-dos: the notes that come back. + * + * This is a view over the notes table, not a second kind of object. A note + * becomes a to-do by being promoted to a commitment (writing `#do`, or the + * editor's follow-up action), which is what gives it a `nextReviewAt`. + */ +export default function Todos() { + const { notes } = useNotes(); + + // "Due" is a function of the clock, so it is state that ticks rather than + // something read during render -- a to-do that comes due while the screen is + // open moves into Due on its own. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(timer); + }, []); + + const { due, upcoming, done } = useMemo(() => { + const commitments = notes.filter( + (note) => note.kind === 'note' && !note.locked && note.nextReviewAt !== null, + ); + return { + due: commitments.filter( + (note) => note.completedAt === null && new Date(note.nextReviewAt!).getTime() <= now, + ), + upcoming: commitments.filter( + (note) => note.completedAt === null && new Date(note.nextReviewAt!).getTime() > now, + ), + done: commitments.filter((note) => note.completedAt !== null), + }; + }, [notes, now]); + + const empty = due.length + upcoming.length + done.length === 0; + + return ( + + + + To-dos + + + {due.length > 0 ? `${due.length} due now` : 'Nothing due'} + + + {empty ? ( + + No to-dos yet. + + Write #do in a note, or use Follow up in the editor. + + + ) : null} + +
+
+
+ + + ); +} + +function Section({ title, notes }: { title: string; notes: Note[] }) { + if (notes.length === 0) return null; + + return ( + + + {title} + + + {notes.map((note, index) => ( + + ))} + + + ); +} + +function TodoRow({ note, first }: { note: Note; first: boolean }) { + const theme = useTheme(); + const router = useRouter(); + const { setCompleted } = useNotes(); + const complete = note.completedAt !== null; + + return ( + + {!first ? : null} + router.push({ pathname: '/note/[id]', params: { id: note.id } })} + className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-60" + > + { + haptics.success(); + void setCompleted(note.id, !complete); + }} + className={[ + 'h-6 w-6 items-center justify-center rounded-full border-2', + complete ? 'border-accent bg-accent dark:border-accent-dark dark:bg-accent-dark' : 'border-faint dark:border-faint-dark', + ].join(' ')} + > + {complete ? : null} + + + + + {note.title ?? 'Untitled'} + + {!complete ? ( + + Comes back {comesBack(note.nextReviewAt!)} + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 15b326a..17a1536 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -8,13 +8,15 @@ import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { SQLiteProvider } from 'expo-sqlite'; import { StatusBar } from 'expo-status-bar'; +import * as SystemUI from 'expo-system-ui'; import { colorScheme as nativewindColorScheme } from 'nativewind'; import { Suspense, useEffect } from 'react'; -import { ActivityIndicator, Platform, View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { migrate } from '@/db/migrations'; +import { NotebooksProvider } from '@/store/notebooks-store'; import { NotesProvider } from '@/store/notes-store'; import { useTheme } from '@/theme'; @@ -22,8 +24,8 @@ import '../global.css'; export const DATABASE_NAME = 'dailynote.db'; -// Hold the splash until the serif is ready: the composer is the launch screen, -// and it must not flash from system font to Newsreader mid-boot. +// Hold the splash until the serif is ready: note titles are set in Newsreader +// and must not flash from the system font to it mid-boot. void SplashScreen.preventAutoHideAsync(); export default function RootLayout() { @@ -41,41 +43,47 @@ export default function RootLayout() { }, [ready]); useEffect(() => { - // Web only: with darkMode 'class' (see tailwind.config.js), dark: styles - // activate via a class on ; keep it mirroring the OS setting. - // Native must not take this path -- set() there overrides the app-level - // appearance instead of following the system. - if (Platform.OS === 'web') { - nativewindColorScheme.set(theme.dark ? 'dark' : 'light'); - } - }, [theme.dark]); + // tailwind.config.js uses darkMode 'class', which means the `dark:` + // variants only activate for the scheme NativeWind has been told about. + // This must run on native too, not just web: without it every `dark:` + // class silently no-ops on device while useTheme() -- which reads + // useColorScheme() directly -- goes dark anyway, so dark navigator chrome + // ends up drawn behind light-styled content. + nativewindColorScheme.set(theme.dark ? 'dark' : 'light'); + // Paints the window behind the React root, killing the white flash + // between the splash screen tearing down and the first frame. + void SystemUI.setBackgroundColorAsync(theme.canvas); + }, [theme]); if (!ready) return null; return ( - + }> - - - - {/* The composer is the launch screen: the app opens with the - cursor already in an empty note. */} - - - - - + + + + + + + + + + + + @@ -86,8 +94,8 @@ export default function RootLayout() { function Booting() { const theme = useTheme(); return ( - - + + ); } diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx deleted file mode 100644 index 7d4c9f0..0000000 --- a/apps/mobile/src/app/index.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { - documentToText, - hasCommitmentTag, - insertImage, - isEmptyDocument, - paragraph, - parseHashtags, - serializeDocument, - setAlign, - toggleBullet, - type Block, -} from '@dailynote/core'; -import { Link } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - KeyboardAvoidingView, - Platform, - Pressable, - ScrollView, - Text, - View, -} from 'react-native'; -import Animated, { - interpolateColor, - useAnimatedStyle, - useSharedValue, - withSequence, - withSpring, -} from 'react-native-reanimated'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { BlockEditor, type BlockEditorHandle } from '@/components/block-editor'; -import { BlockToolbar } from '@/components/block-toolbar'; -import { pickImage } from '@/lib/attachments'; -import { todayHeading } from '@/lib/format'; -import { haptics } from '@/lib/haptics'; -import * as motion from '@/lib/motion'; -import { AnimatedPressable } from '@/lib/motion'; -import { useNotes } from '@/store/notes-store'; -import { useTheme } from '@/theme'; - -/** - * The composer, and the launch screen. - * - * The whole product bets on capture being frictionless, so this screen opens - * focused with an empty note and nothing else competing for attention. Per the - * plan: "if it feels good to type into, you have a product." - */ -export default function Composer() { - const theme = useTheme(); - const { create, promote, notes } = useNotes(); - const [blocks, setBlocks] = useState(() => [paragraph()]); - const [focused, setFocused] = useState(null); - const editor = useRef(null); - const [saving, setSaving] = useState(false); - // Bumped after a save: remounting the editor is what clears it and returns - // the caret to a fresh title, since autoFocus only fires on mount. - const [draft, setDraft] = useState(0); - - const body = documentToText(blocks); - const tags = parseHashtags(body); - const willCommit = hasCommitmentTag(body); - const canSave = !isEmptyDocument(blocks) && !saving; - - // Save pill: springs between its disabled and enabled state instead of - // snapping, and dips under the finger while pressed. - const enabled = useSharedValue(0); - const pressed = useSharedValue(0); - useEffect(() => { - enabled.value = withSpring(canSave ? 1 : 0, motion.SPRING); - }, [canSave, enabled]); - - const pillStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor( - enabled.value, - [0, 1], - [`${theme.line}99`, theme.ink], - ), - transform: [{ scale: (0.96 + 0.04 * enabled.value) * (1 - 0.03 * pressed.value) }], - })); - const pillLabelStyle = useAnimatedStyle(() => ({ - color: interpolateColor(enabled.value, [0, 1], [theme.faint, theme.paper]), - })); - - // The quiet acknowledgment that capture happened: the note count ticks up - // with a small pulse. No toast, no checkmark. - const countPulse = useSharedValue(1); - const prevCount = useRef(notes.length); - useEffect(() => { - if (notes.length > prevCount.current) { - countPulse.value = withSequence( - withSpring(1.15, { ...motion.SPRING, stiffness: 500 }), - withSpring(1, motion.SPRING), - ); - } - prevCount.current = notes.length; - }, [notes.length, countPulse]); - const countStyle = useAnimatedStyle(() => ({ - transform: [{ scale: countPulse.value }], - })); - - const save = useCallback(async () => { - if (isEmptyDocument(blocks) || saving) return; - setSaving(true); - try { - // Both representations are written together: the document is the note, - // `body` is the plain text everything else reads off it. - const note = await create(body, { doc: serializeDocument(blocks) }); - // Writing #do promotes the note to a commitment on the spot -- no extra - // tap, because the tap is what stops people from doing it. - if (hasCommitmentTag(body)) await promote(note.id); - haptics.tap(); - setBlocks([paragraph()]); - setDraft((count) => count + 1); - } finally { - setSaving(false); - } - }, [blocks, body, saving, create, promote]); - - return ( - - - - - - - - {notes.length > 0 ? `${notes.length} notes` : 'Notes'} - - - - - { - pressed.value = withSpring(1, motion.SPRING); - }} - onPressOut={() => { - pressed.value = withSpring(0, motion.SPRING); - }} - style={pillStyle} - className="rounded-full px-4 py-1.5" - > - - Save - - - - - - {todayHeading()} - - - - - - - - {willCommit ? ( - - - - comes back in 2 days - - - ) : null} - - {tags - .filter((tag) => tag !== 'do') - .map((tag) => ( - - #{tag} - - ))} - - {tags.length === 0 ? ( - - - #do makes it come back · #tags are just written inline - - - ) : null} - - - { - if (focused === null) return; - setBlocks(toggleBullet(blocks, focused)); - // The toolbar took the tap; give the caret straight back so the - // keyboard never drops and the next word goes where it should. - editor.current?.restoreFocus(); - }} - onAlign={(align) => { - if (focused === null) return; - setBlocks(setAlign(blocks, focused, align)); - editor.current?.restoreFocus(); - }} - onAddImage={async () => { - const picked = await pickImage(); - if (picked === null) return; - // Functional update: the picker is a round trip through another - // screen, and `blocks` here is from before it opened. - setBlocks((current) => insertImage(current, focused, picked)); - }} - /> - - - - ); -} diff --git a/apps/mobile/src/app/locked.tsx b/apps/mobile/src/app/locked.tsx new file mode 100644 index 0000000..f6074a4 --- /dev/null +++ b/apps/mobile/src/app/locked.tsx @@ -0,0 +1,153 @@ +import { useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Pressable, ScrollView, Text, View } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Icon } from '@/components/icon'; +import { shortDate } from '@/lib/format'; +import { haptics } from '@/lib/haptics'; +import { authenticate } from '@/lib/lock'; +import * as motion from '@/lib/motion'; +import { useGoBack } from '@/lib/nav'; +import { useNotes } from '@/store/notes-store'; +import { useTheme } from '@/theme'; + +type Gate = 'checking' | 'locked' | 'open' | 'unavailable'; + +/** + * Locked notes, behind the device lock. + * + * The gate is re-armed every time the screen mounts: leaving and coming back + * asks again, which is the whole point of locking a note in the first place. + */ +export default function Locked() { + const theme = useTheme(); + const router = useRouter(); + const goBack = useGoBack(); + const { notes, setLocked } = useNotes(); + const [gate, setGate] = useState('checking'); + // Bumped by the Unlock button to re-run the prompt after a refusal. + const [attempt, setAttempt] = useState(0); + + const retry = useCallback(() => { + setGate('checking'); + setAttempt((count) => count + 1); + }, []); + + useEffect(() => { + // The OS prompt is the external system here; the state lands in its + // callback rather than in the effect body. + let active = true; + void authenticate('Unlock your notes').then((result) => { + if (!active) return; + setGate(result.unavailable === true ? 'unavailable' : result.ok ? 'open' : 'locked'); + }); + return () => { + active = false; + }; + }, [attempt]); + + const locked = useMemo( + () => notes.filter((note) => note.locked && !note.deleted && note.kind === 'note'), + [notes], + ); + + return ( + + + + + + Locked notes + + + + {gate === 'open' ? ( + + {locked.length === 0 ? ( + + ) : ( + + {locked.map((note, index) => ( + + {index > 0 ? : null} + + + router.push({ pathname: '/note/[id]', params: { id: note.id } }) + } + className="flex-1 active:opacity-60" + > + + {note.title ?? 'Untitled'} + + + {shortDate(note.createdAt)} + + + + { + haptics.tap(); + void setLocked(note.id, false); + }} + className="rounded-full bg-elevated px-3 py-1.5 active:opacity-60 dark:bg-elevated-dark" + > + + Unlock + + + + + ))} + + )} + + ) : gate === 'unavailable' ? ( + + ) : gate === 'locked' ? ( + + + + These notes are locked. + + + + Unlock + + + + ) : null} + + ); +} + +function Message({ title, detail }: { title: string; detail: string }) { + return ( + + {title} + {detail} + + ); +} diff --git a/apps/mobile/src/app/note/[id].tsx b/apps/mobile/src/app/note/[id].tsx index 81bef0d..4c9f4c2 100644 --- a/apps/mobile/src/app/note/[id].tsx +++ b/apps/mobile/src/app/note/[id].tsx @@ -1,15 +1,19 @@ import { + documentToText, insertImage, + isEmptyDocument, parseDocument, + serializeDocument, setAlign, - threadOf, toggleBullet, + type Align, type Block, type Note, } from '@dailynote/core'; -import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useLocalSearchParams } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + Alert, KeyboardAvoidingView, Platform, Pressable, @@ -17,215 +21,443 @@ import { Text, View, } from 'react-native'; -import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { BlockEditor, type BlockEditorHandle } from '@/components/block-editor'; -import { BlockToolbar } from '@/components/block-toolbar'; -import { pickImage } from '@/lib/attachments'; -import { comesBack, formatDay } from '@/lib/format'; +import { FormatSheet } from '@/components/format-sheet'; +import { Icon, type IconName } from '@/components/icon'; +import { Sheet } from '@/components/sheet'; +import { canAttachImages, pickImage } from '@/lib/attachments'; +import { editedAt } from '@/lib/format'; import { haptics } from '@/lib/haptics'; -import * as motion from '@/lib/motion'; -import { AnimatedPressable } from '@/lib/motion'; +import { useGoBack } from '@/lib/nav'; +import { useHistory } from '@/lib/undo'; +import { useNotebooks } from '@/store/notebooks-store'; import { useNotes } from '@/store/notes-store'; +import { useTheme } from '@/theme'; /** How long the writing has to stop before an edit is written to SQLite. */ const AUTOSAVE_MS = 600; -/** Note detail: edit the note, promote to a commitment, read the thread. */ -export default function NoteDetail() { +export default function NoteScreen() { const { id } = useLocalSearchParams<{ id: string }>(); - const { notes } = useNotes(); + const { notes, loading } = useNotes(); - const note = useMemo(() => notes.find((candidate) => candidate.id === id), [notes, id]); + const note = useMemo(() => notes.find((candidate) => candidate.id === id) ?? null, [notes, id]); - if (note === undefined) { - return ( - - - This note is gone. - - - ); + if (id !== 'new' && note === null) { + // Still loading is not the same as gone, and rendering "gone" over a note + // that is about to appear is worse than rendering nothing for a frame. + return loading ? : ; } - // Keyed on the note id so the draft initialises from storage once, on mount, - // rather than being synced back by an effect -- store refreshes must never - // clobber what the user is currently typing. - return ; + // Keyed so the document initialises from storage exactly once, on mount. + // Store refreshes must never clobber what is being typed. + return ; +} + +function Missing() { + const goBack = useGoBack(); + return ( + + This note is gone. + + Go back + + + ); } -function NoteEditor({ note }: { note: Note }) { - const router = useRouter(); - const { notes, setContent, softDelete, promote } = useNotes(); - const [blocks, setBlocks] = useState(() => parseDocument(note.doc, note.body)); +/** + * The note editor: the reference's chrome around the block writing surface. + * + * The screen owns saving, undo and the menus; `BlockEditor` owns the caret and + * the structure of the document. Undo is over whole documents rather than over + * text, because every edit in the block model already produces a new `Block[]`. + */ +function Editor({ note }: { note: Note | null }) { + const theme = useTheme(); + const goBack = useGoBack(); + const { create, setContent, softDelete, promote, move, togglePin, setLocked } = useNotes(); + const { notebooks, find } = useNotebooks(); + + const history = useHistory( + useMemo(() => parseDocument(note?.doc, note?.body ?? ''), [note?.doc, note?.body]), + ); + const blocks = history.value; + const [focused, setFocused] = useState(null); + const [sheet, setSheet] = useState<'format' | 'more' | 'notebook' | null>(null); const editor = useRef(null); - // Autosave, because there is no longer one input whose blur means "done": - // with a block per line, blur fires every time the caret moves between them. - // So the note is written once the writing pauses, and again on the way out. + // The id is only known after the first save for a brand new note. + const noteId = useRef(note?.id ?? null); + const [editedIso, setEditedIso] = useState(note?.updatedAt ?? new Date().toISOString()); + + const notebook = find(note?.notebookId ?? null); + const count = useMemo( + () => documentToText(blocks).replace(/\s/g, '').length, + [blocks], + ); + + // --- persistence --------------------------------------------------------- + + const saved = useRef(note === null ? null : serializeDocument(parseDocument(note.doc, note.body))); + + const persist = useCallback( + async (next: readonly Block[]) => { + const serialized = serializeDocument(next); + if (serialized === saved.current) return; + // A new note is not written until it has content, so backing out of an + // empty editor leaves nothing behind. + if (noteId.current === null && isEmptyDocument(next)) return; + + saved.current = serialized; + if (noteId.current === null) { + const created = await create(documentToText(next), { doc: serialized }); + noteId.current = created.id; + } else { + await setContent(noteId.current, next); + } + setEditedIso(new Date().toISOString()); + }, + [create, setContent], + ); + + // Debounced autosave. There is no Save button in the reference and there is + // none here: leaving the screen is what commits, and this covers a crash. + useEffect(() => { + const timer = setTimeout(() => void persist(blocks), AUTOSAVE_MS); + return () => clearTimeout(timer); + }, [blocks, persist]); + + // Kept in a ref so the unmount flush can stay on empty deps: an effect + // depending on `blocks` would fire its cleanup on every keystroke. const latest = useRef(blocks); - const unsaved = useRef(false); - const timer = useRef | null>(null); - - const flush = useCallback(() => { - if (!unsaved.current) return; - unsaved.current = false; - void setContent(note.id, latest.current); - }, [note.id, setContent]); - - // Kept in a ref so the unmount effect below can stay on empty deps. `flush` - // closes over `setContent`, whose identity changes on every store refresh -- - // an effect depending on it would re-run its cleanup, and saving from a - // cleanup that fires on every save is a loop. - const flushRef = useRef(flush); useEffect(() => { - flushRef.current = flush; - }); + latest.current = blocks; + }, [blocks]); + + const persistRef = useRef(persist); + useEffect(() => { + persistRef.current = persist; + }, [persist]); useEffect(() => { return () => { - if (timer.current !== null) clearTimeout(timer.current); - flushRef.current(); + void persistRef.current(latest.current); }; }, []); - const change = useCallback((next: Block[]) => { - setBlocks(next); - latest.current = next; - unsaved.current = true; + // --- editing ------------------------------------------------------------- - if (timer.current !== null) clearTimeout(timer.current); - timer.current = setTimeout(() => flushRef.current(), AUTOSAVE_MS); - }, []); + const change = useCallback((next: Block[]) => history.set(next), [history]); - const updates = useMemo( - () => threadOf(notes, note.rootId).filter((candidate) => candidate.id !== note.id), - [notes, note.rootId, note.id], + /** A toolbar edit: its own undo step, and the caret goes back to the note. */ + const apply = useCallback( + (next: Block[]) => { + history.set(next, { discrete: true }); + editor.current?.restoreFocus(); + }, + [history], ); - const isCommitment = note.nextReviewAt !== null; + const undo = useCallback(() => { + if (history.undo() !== null) haptics.tap(); + }, [history]); + + const redo = useCallback(() => { + if (history.redo() !== null) haptics.tap(); + }, [history]); + + // The toolbar acts on the focused block, or on the last one when the caret + // has left the note -- pressing a format button should never do nothing. + const target = focused ?? blocks.length - 1; + const targetBlock = blocks[target]; + + const onToggleBullet = useCallback(() => { + haptics.tap(); + apply(toggleBullet(blocks, target)); + }, [apply, blocks, target]); + + const onAlign = useCallback( + (align: Align) => { + haptics.tap(); + apply(setAlign(blocks, target, align)); + }, + [apply, blocks, target], + ); + + const onAddImage = useCallback(() => { + setSheet(null); + void (async () => { + const block = await pickImage(); + if (block === null) return; + haptics.success(); + apply(insertImage(blocks, focused, block)); + })(); + }, [apply, blocks, focused]); + + // --- actions ------------------------------------------------------------- + + const done = useCallback(async () => { + haptics.tap(); + await persist(blocks); + goBack(); + }, [persist, blocks, goBack]); + + const requireSaved = useCallback(async () => { + await persist(blocks); + return noteId.current; + }, [persist, blocks]); + + const confirmDelete = useCallback(() => { + setSheet(null); + Alert.alert('Delete this note?', 'You can restore it from Recently deleted.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + haptics.tap(); + // Nothing to delete if it was never saved; block the unmount flush + // from writing it back on the way out. + saved.current = serializeDocument(blocks); + if (noteId.current !== null) await softDelete(noteId.current); + goBack(); + })(); + }, + }, + ]); + }, [softDelete, goBack, blocks]); return ( - - ( - { - haptics.tap(); - await softDelete(note.id); - router.back(); - }} - > - Delete - - ), - }} - /> + + + void done()} /> - - - {formatDay(note.createdAt)} + + + + + + setSheet('more')} /> + void done()} /> + + + + + {editedAt(editedIso)} + {count > 0 ? ` | ${count}` : ''} + {` | ${notebook?.name ?? 'Default notebook'}`} + + + + + + + + {/* The reference's editing rail, pinned above the keyboard. */} + + setSheet('format')} + tint={theme.accent} + /> + + onAlign('left')} /> + onAlign('center')} /> + + + + + setSheet(null)} + block={targetBlock} + onToggleBullet={onToggleBullet} + onAlign={onAlign} + onAddImage={onAddImage} + /> - - - {/* The follow-through loop's entry point. Promoting crossfades the - pill into the status with a spring reflow and the app's single - success haptic -- it should feel like something latched, quietly. */} - - {isCommitment ? ( - // Forward-looking only. `reviewCount` drives the ladder but is - // never shown: a tally of how many times you deferred something is - // a guilt counter, and guilt is the failure mode for this app. - - - - Comes back {comesBack(note.nextReviewAt!)} - - - ) : ( - setSheet(null)} title="Note"> + + { + setSheet(null); + void (async () => { + const id = await requireSaved(); + if (id !== null) await togglePin(id); + })(); + }} + /> + setSheet('notebook')} + /> + { + setSheet(null); + haptics.success(); + void (async () => { + const id = await requireSaved(); + if (id !== null) await promote(id); + })(); + }} + /> + { + setSheet(null); + void (async () => { + const id = await requireSaved(); + if (id !== null) { + await setLocked(id, true); + goBack(); + } + })(); + }} + /> + + + + + setSheet(null)} title="Move to"> + + { + setSheet(null); + void (async () => { + const id = await requireSaved(); + if (id !== null) await move(id, null); + })(); + }} + /> + {notebooks.map((option) => ( + { - haptics.success(); - void promote(note.id); + setSheet(null); + void (async () => { + const id = await requireSaved(); + if (id !== null) await move(id, option.id); + })(); }} - > - - Follow up - - - )} - - - {updates.length > 0 ? ( - - - Updates - - {updates.map((update, index) => ( - - - {formatDay(update.createdAt)} - - - {update.body} - - - ))} - + /> + ))} + + + + ); +} + +function MenuRow({ + icon, + label, + detail, + selected = false, + destructive = false, + onPress, +}: { + icon: IconName; + label: string; + detail?: string; + selected?: boolean; + destructive?: boolean; + onPress: () => void; +}) { + const theme = useTheme(); + const tint = destructive ? theme.muted : theme.ink; + + return ( + + + + + {label} + + {detail !== undefined ? ( + {detail} ) : null} - - - { - if (focused === null) return; - change(toggleBullet(blocks, focused)); - // The toolbar took the tap; give the caret straight back so the - // keyboard never drops and the next word goes where it should. - editor.current?.restoreFocus(); - }} - onAlign={(align) => { - if (focused === null) return; - change(setAlign(blocks, focused, align)); - editor.current?.restoreFocus(); - }} - onAddImage={async () => { - const picked = await pickImage(); - if (picked === null) return; - // `latest` rather than `blocks`: the picker is a round trip through - // another screen, and this closure is from before it opened. - change(insertImage(latest.current, focused, picked)); - }} - /> - + + {selected ? : null} + + ); +} + +function ToolbarIcon({ + name, + label, + onPress, + disabled = false, + tint, +}: { + name: IconName; + label: string; + onPress: () => void; + disabled?: boolean; + tint?: string; +}) { + const theme = useTheme(); + return ( + + + ); } diff --git a/apps/mobile/src/app/notebooks.tsx b/apps/mobile/src/app/notebooks.tsx new file mode 100644 index 0000000..35e8e12 --- /dev/null +++ b/apps/mobile/src/app/notebooks.tsx @@ -0,0 +1,356 @@ +import { ALL_NOTES, DEFAULT_NOTEBOOK, type Notebook } from '@dailynote/core'; +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { Alert, Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Icon, type IconName } from '@/components/icon'; +import { Sheet } from '@/components/sheet'; +import { haptics } from '@/lib/haptics'; +import * as motion from '@/lib/motion'; +import { useGoBack } from '@/lib/nav'; +import { useNotebooks } from '@/store/notebooks-store'; +import { useNotes } from '@/store/notes-store'; +import { swatchColor, useTheme } from '@/theme'; + +/** The colours a new notebook can be given, as palette keys. */ +const SWATCHES = ['swatch', 'swatchWarm', 'accent'] as const; + +/** + * The notebooks drawer. + * + * Three groups, as in the reference: everything, the user's own notebooks, and + * the fixed shelves (Default, Locked, Recently deleted) that cannot be renamed + * or removed. + */ +export default function Notebooks() { + const theme = useTheme(); + const router = useRouter(); + const goBack = useGoBack(); + const { notes, refresh: refreshNotes } = useNotes(); + const { notebooks, create, remove, rename, recolor, refresh: refreshNotebooks } = useNotebooks(); + + // One sheet serves both actions: null while closed, a notebook when + // renaming, 'new' when creating. + const [target, setTarget] = useState(null); + const [name, setName] = useState(''); + const [color, setColor] = useState('swatch'); + const [editing, setEditing] = useState(false); + + const counts = useMemo(() => { + const byNotebook = new Map(); + let total = 0; + let unfiled = 0; + let locked = 0; + + for (const note of notes) { + if (note.kind !== 'note') continue; + if (note.locked) { + locked += 1; + continue; + } + total += 1; + if (note.notebookId === null) unfiled += 1; + else byNotebook.set(note.notebookId, (byNotebook.get(note.notebookId) ?? 0) + 1); + } + return { byNotebook, total, unfiled, locked }; + }, [notes]); + + const trashCount = useMemo(() => notes.filter((note) => note.deleted).length, [notes]); + + const openCreate = () => { + setName(''); + setColor('swatch'); + setTarget('new'); + }; + + const openRename = (notebook: Notebook) => { + setName(notebook.name); + setColor(notebook.color); + setTarget(notebook); + }; + + const submit = async () => { + const trimmed = name.trim(); + if (trimmed.length === 0 || target === null) return; + haptics.tap(); + if (target === 'new') { + await create(trimmed, color); + } else { + await rename(target.id, trimmed); + if (color !== target.color) await recolor(target.id, color); + } + setTarget(null); + }; + + const confirmRemove = (notebook: Notebook) => { + const count = counts.byNotebook.get(notebook.id) ?? 0; + Alert.alert( + `Delete "${notebook.name}"?`, + count > 0 + ? `Its ${count === 1 ? 'note' : `${count} notes`} will move to the Default notebook.` + : 'This notebook is empty.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + await remove(notebook.id); + // remove() re-files the notebook's notes, so the notes store is + // stale until it re-reads. + await refreshNotes(); + await refreshNotebooks(); + })(); + }, + }, + ], + ); + }; + + /** Filtering the list is the drawer's whole job, so every row closes it. */ + const openFilter = (filter: string) => { + router.dismissTo({ pathname: '/', params: { filter } }); + }; + + return ( + + + + + + Notebooks + setEditing((on) => !on)} + className="h-11 w-11 items-center justify-center rounded-full active:opacity-50" + > + + + + + + + openFilter(ALL_NOTES)} + /> + + + + My notebooks + + New + + + + {notebooks.length === 0 ? ( + + No notebooks yet. Tap New to make one. + + ) : ( + + {notebooks.map((notebook, index) => ( + (editing ? openRename(notebook) : openFilter(notebook.id))} + onRemove={() => confirmRemove(notebook)} + /> + ))} + + )} + + + + openFilter(DEFAULT_NOTEBOOK)} + /> + 0 ? String(counts.locked) : undefined} + trailingIcon="lock" + onPress={() => router.push('/locked')} + /> + router.push('/trash')} + /> + + + + + setTarget(null)} + title={target === 'new' ? 'New notebook' : 'Rename notebook'} + > + + + + + {SWATCHES.map((key) => ( + setColor(key)} + className={[ + 'h-10 w-10 items-center justify-center rounded-full border-2', + color === key ? 'border-ink dark:border-ink-dark' : 'border-transparent', + ].join(' ')} + > + + + ))} + + + + + + {target === 'new' ? 'Create' : 'Save'} + + + + + + + ); +} + +function Group({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +function Row({ + icon, + swatch, + label, + trailing, + trailingIcon, + first = false, + highlighted = false, + editing = false, + onPress, + onRemove, +}: { + icon?: IconName; + swatch?: string; + label: string; + trailing?: string; + trailingIcon?: IconName; + first?: boolean; + highlighted?: boolean; + editing?: boolean; + onPress: () => void; + onRemove?: () => void; +}) { + const theme = useTheme(); + + return ( + + {!first ? : null} + + + {swatch !== undefined ? ( + + ) : icon !== undefined ? ( + + ) : null} + + + {label} + + {editing && onRemove !== undefined ? ( + + + + + + ) : ( + <> + {trailing !== undefined ? ( + {trailing} + ) : null} + {trailingIcon !== undefined ? ( + + ) : null} + + + )} + + + ); +} diff --git a/apps/mobile/src/app/notes.tsx b/apps/mobile/src/app/notes.tsx deleted file mode 100644 index d27de30..0000000 --- a/apps/mobile/src/app/notes.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { searchNotes, type Note } from '@dailynote/core'; -import { Link, Stack, useRouter } from 'expo-router'; -import { useMemo, useRef, useState } from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; -import ReanimatedSwipeable, { - type SwipeableMethods, -} from 'react-native-gesture-handler/ReanimatedSwipeable'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; - -import { relativeDay } from '@/lib/format'; -import { haptics } from '@/lib/haptics'; -import * as motion from '@/lib/motion'; -import { AnimatedPressable } from '@/lib/motion'; -import { useNotes } from '@/store/notes-store'; -import { useTheme } from '@/theme'; - -/** - * The notes list: a shelf of cards on a deeper background, with a large - * title and a floating "+" back to the composer. The composer stays the - * launch screen -- this is the library, not the front door. - * - * Search is a JS filter over the in-memory array (plan section 0.2, option A). - * The ranking lives in @dailynote/core so it is unit tested without a simulator. - */ -export default function NotesList() { - const theme = useTheme(); - const router = useRouter(); - const { notes, loading } = useNotes(); - const [query, setQuery] = useState(''); - - const rootNotes = useMemo( - // Only root notes in the list; updates are shown inside their thread. - () => notes.filter((note) => note.kind === 'note'), - [notes], - ); - const results = useMemo(() => searchNotes(rootNotes, query), [rootNotes, query]); - - const fabPressed = useSharedValue(0); - const fabStyle = useAnimatedStyle(() => ({ - transform: [{ scale: 1 - 0.08 * fabPressed.value }], - })); - - return ( - - - - result.note.id} - keyboardDismissMode="on-drag" - contentContainerClassName="pb-28" - itemLayoutAnimation={motion.layout} - ListHeaderComponent={ - - - Notes - - - {rootNotes.length === 1 ? '1 note' : `${rootNotes.length} notes`} - {' · private, on this device'} - - - - } - ListEmptyComponent={ - loading ? null : ( - - - {query.length > 0 ? 'Nothing matches.' : 'No notes yet.'} - - {query.length === 0 ? ( - - What you write stays on this device. - - ) : null} - - ) - } - renderItem={({ item, index }) => } - /> - - {/* Floating compose button, MIUI-style; it returns to the composer - rather than opening a second one -- capture has one front door. */} - { - haptics.tap(); - router.navigate('/'); - }} - onPressIn={() => { - fabPressed.value = withSpring(1, motion.SPRING); - }} - onPressOut={() => { - fabPressed.value = withSpring(0, motion.SPRING); - }} - style={fabStyle} - className="absolute bottom-8 right-5 h-14 w-14 items-center justify-center rounded-full bg-accent dark:bg-accent-dark" - > - + - - - ); -} - -function NoteCard({ note, index }: { note: Note; index: number }) { - const { softDelete } = useNotes(); - const swipeable = useRef(null); - const preview = note.body.split('\n').slice(1).join(' ').trim(); - - // Cards dip very slightly under the finger; the spring makes it feel like - // paper giving, not a button. - const pressed = useSharedValue(0); - const pressStyle = useAnimatedStyle(() => ({ - transform: [{ scale: 1 - 0.02 * pressed.value }], - })); - - return ( - // Entering/exiting live on this wrapper, not the pressable: a layout - // animation and the press-scale transform on the same view fight over - // `transform`, and Reanimated warns about exactly that. - - ( - // Muted, not red: deletion here is a soft delete, and alarm colours - // would make tidying feel like danger. - { - haptics.tap(); - swipeable.current?.close(); - void softDelete(note.id); - }} - className="ml-3 w-20 items-center justify-center rounded-2xl bg-line/60 active:opacity-60 dark:bg-line-dark/60" - > - Delete - - )} - > - - { - pressed.value = withSpring(1, motion.SPRING); - }} - onPressOut={() => { - pressed.value = withSpring(0, motion.SPRING); - }} - style={pressStyle} - className="rounded-2xl border border-line/60 bg-card px-4 py-3.5 dark:border-line-dark/60 dark:bg-card-dark" - > - - {note.nextReviewAt !== null ? ( - - ) : null} - - {note.title ?? 'Untitled'} - - - - {preview.length > 0 ? ( - - {preview} - - ) : null} - - - {relativeDay(note.createdAt)} - - - - - - ); -} diff --git a/apps/mobile/src/app/trash.tsx b/apps/mobile/src/app/trash.tsx new file mode 100644 index 0000000..abfb8ea --- /dev/null +++ b/apps/mobile/src/app/trash.tsx @@ -0,0 +1,159 @@ +import type { Note } from '@dailynote/core'; +import { useMemo } from 'react'; +import { Alert, Pressable, ScrollView, Text, View } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Icon } from '@/components/icon'; +import { shortDate } from '@/lib/format'; +import { haptics } from '@/lib/haptics'; +import * as motion from '@/lib/motion'; +import { useGoBack } from '@/lib/nav'; +import { useNotes } from '@/store/notes-store'; +import { useTheme } from '@/theme'; + +/** + * Recently deleted. + * + * Deletion everywhere else in the app is soft, so this is the only screen that + * can destroy anything -- and it always asks first. + */ +export default function Trash() { + const theme = useTheme(); + const goBack = useGoBack(); + const { notes, restore, purge, purgeAll } = useNotes(); + + const deleted = useMemo( + () => + notes + .filter((note) => note.deleted && note.kind === 'note') + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), + [notes], + ); + + const confirmEmpty = () => { + Alert.alert( + 'Delete everything here?', + `${deleted.length === 1 ? 'This note' : `These ${deleted.length} notes`} cannot be recovered.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + haptics.tap(); + void purgeAll(); + }, + }, + ], + ); + }; + + return ( + + + + + + + Recently deleted + + + + + + + + {deleted.length === 0 ? ( + + Nothing here. + + Deleted notes wait here until you empty this list. + + + ) : ( + + {deleted.map((note, index) => ( + { + haptics.tap(); + void restore(note.id); + }} + onPurge={() => { + haptics.tap(); + void purge(note.id); + }} + /> + ))} + + )} + + + ); +} + +function TrashRow({ + note, + first, + onRestore, + onPurge, +}: { + note: Note; + first: boolean; + onRestore: () => void; + onPurge: () => void; +}) { + const theme = useTheme(); + + return ( + + {!first ? : null} + + + + {note.title ?? 'Untitled'} + + + Deleted {shortDate(note.updatedAt)} + + + + + Restore + + + + + + + + ); +} diff --git a/apps/mobile/src/components/block-editor.tsx b/apps/mobile/src/components/block-editor.tsx index 62c7463..7f7bbc7 100644 --- a/apps/mobile/src/components/block-editor.tsx +++ b/apps/mobile/src/components/block-editor.tsx @@ -268,9 +268,7 @@ export function BlockEditor({ // A fixed gutter rather than a character in the text: the marker // is structure, so it cannot be typed over, and a line that wraps // hangs under the first word instead of under the dot. - - • - + • ) : null} @@ -352,12 +351,10 @@ function ImageRow({ block, onRemove }: { block: ImageBlock; onRemove: () => void // No file system here. The note still reads; the photo is on the // device that took it. - - Photo - + Photo ) : ( = { - left: 'flex-start', - center: 'center', - right: 'flex-end', -}; - -export interface BlockToolbarProps { - /** The focused block, or undefined when nothing is. */ - block: Block | undefined; - onToggleBullet: () => void; - onAlign: (align: Align) => void; - onAddImage: () => void; -} - -export function BlockToolbar({ block, onToggleBullet, onAlign, onAddImage }: BlockToolbarProps) { - // Nothing to format when the caret is nowhere at all. - if (block === undefined) return null; - - return ( - - {isTextBlock(block) ? ( - - - •  List - - - ) : null} - - {ALIGNMENTS.map((align) => ( - onAlign(align)}> - - - ))} - - {canAttachImages ? ( - - - - ) : null} - - ); -} - -/** A frame with a horizon and a sun in it -- a picture, at 16 pixels. */ -function PhotoIcon() { - return ( - - - - - ); -} - -/** - * Three stacked bars, the short ones pushed to the edge the text will go to. - * - * Drawn rather than set in a font: the app ships no icon set, and the shape is - * four rectangles. - */ -function AlignIcon({ align, active }: { align: Align; active: boolean }) { - const bar = active - ? 'h-[1.5px] rounded-full bg-accent dark:bg-accent-dark' - : 'h-[1.5px] rounded-full bg-muted dark:bg-muted-dark'; - - return ( - - - - - - - ); -} - -function ToolbarButton({ - children, - active, - onPress, -}: { - children: ReactNode; - active: boolean; - onPress: () => void; -}) { - return ( - { - haptics.tap(); - onPress(); - }} - hitSlop={6} - className={ - active - ? 'rounded-full bg-accent/10 px-3 py-1.5 active:opacity-60 dark:bg-accent-dark/15' - : 'rounded-full px-3 py-1.5 active:opacity-60' - } - > - {children} - - ); -} diff --git a/apps/mobile/src/components/format-sheet.tsx b/apps/mobile/src/components/format-sheet.tsx new file mode 100644 index 0000000..111cc75 --- /dev/null +++ b/apps/mobile/src/components/format-sheet.tsx @@ -0,0 +1,92 @@ +import { isTextBlock, type Align, type Block } from '@dailynote/core'; +import { Text, View } from 'react-native'; + +import { Icon } from '@/components/icon'; +import { Sheet, SheetButton } from '@/components/sheet'; +import { canAttachImages } from '@/lib/attachments'; +import { useTheme } from '@/theme'; + +const ALIGNMENTS: readonly { key: Align; icon: 'alignLeft' | 'alignCenter' | 'alignRight' }[] = [ + { key: 'left', icon: 'alignLeft' }, + { key: 'center', icon: 'alignCenter' }, + { key: 'right', icon: 'alignRight' }, +]; + +export interface FormatSheetProps { + visible: boolean; + onClose: () => void; + /** The block the caret is in, or undefined when nothing is focused. */ + block: Block | undefined; + onToggleBullet: () => void; + onAlign: (align: Align) => void; + onAddImage: () => void; +} + +/** + * The formatting panel. + * + * Every control reports what is applied to the focused block, so the sheet + * doubles as a readout: opening it tells you what the line you are on already + * is. What it offers is exactly what the block document can express -- bullets, + * alignment and images -- rather than a menu of controls that would have + * nowhere to be stored. + */ +export function FormatSheet({ + visible, + onClose, + block, + onToggleBullet, + onAlign, + onAddImage, +}: FormatSheetProps) { + const theme = useTheme(); + const text = block !== undefined && isTextBlock(block) ? block : undefined; + + return ( + + + + {ALIGNMENTS.map((option) => ( + onAlign(option.key)} + /> + ))} + + + + + + {canAttachImages + ? 'Add a photo from your library. Long-press one in the note to remove it.' + : 'Photos need the phone; the web build has nowhere to keep them.'} + + + + {block === undefined ? ( + + + + Tap a line in the note to format it. + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/icon.tsx b/apps/mobile/src/components/icon.tsx new file mode 100644 index 0000000..6be4288 --- /dev/null +++ b/apps/mobile/src/components/icon.tsx @@ -0,0 +1,83 @@ +import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; +import type { ComponentProps } from 'react'; +import type { ColorValue } from 'react-native'; + +/** + * The app's icon vocabulary, in one place. + * + * Screens name what they mean ("overflow", "pin") rather than which glyph in + * which family draws it, so swapping a glyph is a one-line change here instead + * of a hunt through the UI. Two families only: Feather for chrome, Material + * Community for the editor's formatting controls, which Feather has no + * equivalents for. + */ + +type FeatherName = ComponentProps['name']; +type MaterialName = ComponentProps['name']; + +const FEATHER = { + search: 'search', + overflow: 'more-vertical', + back: 'arrow-left', + close: 'x', + done: 'check', + chevron: 'chevron-right', + plus: 'plus', + lock: 'lock', + trash: 'trash-2', + todos: 'check-square', + mic: 'mic', + add: 'plus-circle', + photos: 'image', + camera: 'camera', + files: 'folder', + allNotes: 'file-text', + edit: 'edit-2', +} as const satisfies Record; + +const MATERIAL = { + notes: 'note-text-outline', + notebook: 'notebook-outline', + notebooks: 'notebook-multiple', + reorder: 'format-list-checks', + undo: 'undo-variant', + redo: 'redo-variant', + pin: 'pin-outline', + unpin: 'pin-off-outline', + + // Format sheet. + textSize: 'format-size', + bold: 'format-bold', + italic: 'format-italic', + underline: 'format-underline', + strikethrough: 'format-strikethrough-variant', + bulletList: 'format-list-bulleted', + numberedList: 'format-list-numbered', + checklist: 'format-list-checkbox', + indent: 'format-indent-increase', + outdent: 'format-indent-decrease', + alignLeft: 'format-align-left', + alignCenter: 'format-align-center', + alignRight: 'format-align-right', + highlight: 'marker', + doodle: 'draw', + spreadsheet: 'table', +} as const satisfies Record; + +export type IconName = keyof typeof FEATHER | keyof typeof MATERIAL; + +export interface IconProps { + name: IconName; + size?: number; + /** Widened past `string` so navigator tint callbacks can pass theirs through. */ + color: ColorValue; +} + +export function Icon({ name, size = 22, color }: IconProps) { + if (name in FEATHER) { + const glyph = FEATHER[name as keyof typeof FEATHER]; + return ; + } + const glyph = MATERIAL[name as keyof typeof MATERIAL]; + return ; +} diff --git a/apps/mobile/src/components/sheet.tsx b/apps/mobile/src/components/sheet.tsx new file mode 100644 index 0000000..961a5bf --- /dev/null +++ b/apps/mobile/src/components/sheet.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from 'react'; +import { Modal, Pressable, Text, View } from 'react-native'; +import Animated, { SlideInDown, SlideOutDown } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Icon } from '@/components/icon'; +import * as motion from '@/lib/motion'; +import { useTheme } from '@/theme'; + +/** + * The bottom sheet the Format and Add panels live in. + * + * Deliberately not @gorhom/bottom-sheet: neither panel is draggable or + * snap-pointed in the reference -- they appear, you tap something, they go + * away -- so a Modal with a slide transition is the whole requirement, and it + * avoids a dependency that would need its own gesture plumbing. + */ +export interface SheetProps { + visible: boolean; + onClose: () => void; + title: string; + children: ReactNode; +} + +export function Sheet({ visible, onClose, title, children }: SheetProps) { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + + return ( + + {/* Tapping the scrim dismisses; the sheet itself swallows the press. */} + + + + + event.stopPropagation()}> + + + + {title} + + + + + + + {children} + + + + + + ); +} + +/** A square icon button on the sheet's elevated surface. */ +export function SheetButton({ + icon, + label, + active = false, + disabled = false, + onPress, + flex = true, +}: { + icon: Parameters[0]['name']; + label: string; + active?: boolean; + disabled?: boolean; + onPress: () => void; + flex?: boolean; +}) { + const theme = useTheme(); + const tint = disabled ? theme.faint : active ? theme.accentInk : theme.ink; + + return ( + + + + ); +} diff --git a/apps/mobile/src/db/migrations.ts b/apps/mobile/src/db/migrations.ts index 5580448..51652ec 100644 --- a/apps/mobile/src/db/migrations.ts +++ b/apps/mobile/src/db/migrations.ts @@ -60,6 +60,41 @@ const MIGRATIONS: readonly ((db: SQLiteDatabase) => Promise)[] = [ async (db) => { await db.execAsync('alter table notes add column doc text;'); }, + + // v3 -- notebooks, locking and pinning. + // + // A note with a null notebook_id is in the Default notebook. Modelling the + // default as "no row" rather than a seeded row means there is no per-install + // id to reconcile when sync lands, and no way for a user to delete the + // bucket their notes fall back into. + async (db) => { + await db.execAsync(` + create table notebooks ( + id text primary key not null, + user_id text, + name text not null, + -- Palette key, not a hex value: notebooks re-theme with the app. + color text not null default 'swatch', + sort_order integer not null default 0, + + created_at text not null, + updated_at text not null, + deleted integer not null default 0 + ); + + alter table notes add column notebook_id text + references notebooks(id) on delete set null; + + -- Notes hidden behind the device lock. See lib/lock.ts. + alter table notes add column locked integer not null default 0; + + -- Manual pin to the top of the list, independent of updated_at. + alter table notes add column pinned_at text; + + create index notes_notebook_idx on notes (notebook_id); + create index notebooks_sort_idx on notebooks (sort_order); + `); + }, ]; /** diff --git a/apps/mobile/src/db/notebooks-repo.ts b/apps/mobile/src/db/notebooks-repo.ts new file mode 100644 index 0000000..abcf996 --- /dev/null +++ b/apps/mobile/src/db/notebooks-repo.ts @@ -0,0 +1,132 @@ +import type { Notebook } from '@dailynote/core'; +import type { SQLiteDatabase } from 'expo-sqlite'; + +import { newId } from './notes-repo'; + +/** + * Notebook persistence, mirroring the shape of `notes-repo`. + * + * There is no row for the Default notebook -- a note with a null `notebookId` + * is in it. Nothing here should ever create one. + */ +export interface NotebooksRepo { + all(): Promise; + create(name: string, color?: string): Promise; + rename(id: string, name: string): Promise; + recolor(id: string, color: string): Promise; + reorder(ids: readonly string[]): Promise; + /** Soft-deletes the notebook and empties it back into the Default notebook. */ + remove(id: string): Promise; +} + +interface NotebookRow { + id: string; + user_id: string | null; + name: string; + color: string; + sort_order: number; + created_at: string; + updated_at: string; + deleted: number; +} + +function toNotebook(row: NotebookRow): Notebook { + return { + id: row.id, + userId: row.user_id, + name: row.name, + color: row.color, + sortOrder: row.sort_order, + createdAt: row.created_at, + updatedAt: row.updated_at, + deleted: row.deleted === 1, + }; +} + +export function createNotebooksRepo(db: SQLiteDatabase): NotebooksRepo { + return { + async all() { + const rows = await db.getAllAsync( + 'select * from notebooks where deleted = 0 order by sort_order, created_at', + ); + return rows.map(toNotebook); + }, + + async create(name, color = 'swatch') { + const now = new Date().toISOString(); + const row = await db.getFirstAsync<{ next: number }>( + 'select coalesce(max(sort_order), -1) + 1 as next from notebooks', + ); + const notebook: Notebook = { + id: newId(), + userId: null, + name: name.trim(), + color, + sortOrder: row?.next ?? 0, + createdAt: now, + updatedAt: now, + deleted: false, + }; + + await db.runAsync( + `insert into notebooks + (id, user_id, name, color, sort_order, created_at, updated_at, deleted) + values (?, ?, ?, ?, ?, ?, ?, 0)`, + [ + notebook.id, + notebook.userId, + notebook.name, + notebook.color, + notebook.sortOrder, + notebook.createdAt, + notebook.updatedAt, + ], + ); + return notebook; + }, + + async rename(id, name) { + await db.runAsync('update notebooks set name = ?, updated_at = ? where id = ?', [ + name.trim(), + new Date().toISOString(), + id, + ]); + }, + + async recolor(id, color) { + await db.runAsync('update notebooks set color = ?, updated_at = ? where id = ?', [ + color, + new Date().toISOString(), + id, + ]); + }, + + async reorder(ids) { + const now = new Date().toISOString(); + await db.withTransactionAsync(async () => { + for (const [index, id] of ids.entries()) { + await db.runAsync( + 'update notebooks set sort_order = ?, updated_at = ? where id = ?', + [index, now, id], + ); + } + }); + }, + + async remove(id) { + const now = new Date().toISOString(); + // Deleting a notebook must not delete what is filed in it: the notes + // fall back to the Default notebook rather than disappearing with it. + await db.withTransactionAsync(async () => { + await db.runAsync( + 'update notes set notebook_id = null, updated_at = ? where notebook_id = ?', + [now, id], + ); + await db.runAsync( + 'update notebooks set deleted = 1, updated_at = ? where id = ?', + [now, id], + ); + }); + }, + }; +} diff --git a/apps/mobile/src/db/notes-repo.ts b/apps/mobile/src/db/notes-repo.ts index fa3554c..17bd6ee 100644 --- a/apps/mobile/src/db/notes-repo.ts +++ b/apps/mobile/src/db/notes-repo.ts @@ -13,6 +13,8 @@ import type { SQLiteDatabase } from 'expo-sqlite'; */ export interface NotesRepo { all(): Promise; + /** Soft-deleted notes, most recently deleted first. Powers Recently deleted. */ + deleted(): Promise; get(id: string): Promise; create(body: string, options?: CreateOptions): Promise; update(id: string, patch: Partial): Promise; @@ -21,6 +23,9 @@ export interface NotesRepo { setContent(id: string, blocks: readonly Block[]): Promise; softDelete(id: string): Promise; restore(id: string): Promise; + /** Irreversible. Only reachable from Recently deleted. */ + purge(id: string): Promise; + purgeAll(): Promise; } export interface CreateOptions { @@ -32,6 +37,7 @@ export interface CreateOptions { rootId?: string; parentId?: string | null; kind?: Note['kind']; + notebookId?: string | null; } /** Client-generated UUIDs: offline-safe, and public URLs need no remap later. */ @@ -45,6 +51,9 @@ interface NoteRow { body: string; title: string | null; doc: string | null; + notebook_id: string | null; + locked: number; + pinned_at: string | null; root_id: string; parent_id: string | null; kind: string; @@ -70,6 +79,9 @@ function toNote(row: NoteRow): Note { body: row.body, title: row.title, doc: row.doc, + notebookId: row.notebook_id, + locked: row.locked === 1, + pinnedAt: row.pinned_at, rootId: row.root_id, parentId: row.parent_id, kind: row.kind as Note['kind'], @@ -96,6 +108,9 @@ const COLUMNS: Record = { body: 'body', title: 'title', doc: 'doc', + notebookId: 'notebook_id', + locked: 'locked', + pinnedAt: 'pinned_at', rootId: 'root_id', parentId: 'parent_id', kind: 'kind', @@ -123,8 +138,19 @@ function toColumnValue(value: unknown): string | number | null { export function createNotesRepo(db: SQLiteDatabase): NotesRepo { return { async all() { + // Pinned notes float to the top, most recently pinned first; everything + // else falls back to recency. `pinned_at is null` sorts 0 before 1, so + // ascending on that expression puts pinned rows first. const rows = await db.getAllAsync( - 'select * from notes where deleted = 0 order by updated_at desc', + `select * from notes where deleted = 0 + order by pinned_at is null, pinned_at desc, updated_at desc`, + ); + return rows.map(toNote); + }, + + async deleted() { + const rows = await db.getAllAsync( + 'select * from notes where deleted = 1 order by updated_at desc', ); return rows.map(toNote); }, @@ -203,5 +229,13 @@ export function createNotesRepo(db: SQLiteDatabase): NotesRepo { async restore(id) { await this.update(id, { deleted: false }); }, + + async purge(id) { + await db.runAsync('delete from notes where id = ?', id); + }, + + async purgeAll() { + await db.runAsync('delete from notes where deleted = 1'); + }, }; } diff --git a/apps/mobile/src/lib/format.ts b/apps/mobile/src/lib/format.ts index b9b49a6..5311808 100644 --- a/apps/mobile/src/lib/format.ts +++ b/apps/mobile/src/lib/format.ts @@ -38,3 +38,24 @@ export function comesBack(iso: string, now: Date = new Date()): string { export function todayHeading(now: Date = new Date()): string { return now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); } + +/** + * The list row's numeric date: "31/07/2026". + * + * Fixed to day/month/year rather than the locale's order, because it sits + * inline in front of the preview text where a variable-width date would make + * the previews fail to line up down the list. + */ +export function shortDate(iso: string): string { + const date = new Date(iso); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`; +} + +/** The editor's meta line: "04/08/2026, 5:08 pm". */ +export function editedAt(iso: string): string { + const time = new Date(iso) + .toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + .toLowerCase(); + return `${shortDate(iso)}, ${time}`; +} diff --git a/apps/mobile/src/lib/lock.ts b/apps/mobile/src/lib/lock.ts new file mode 100644 index 0000000..b25f144 --- /dev/null +++ b/apps/mobile/src/lib/lock.ts @@ -0,0 +1,36 @@ +import * as LocalAuthentication from 'expo-local-authentication'; +import { Platform } from 'react-native'; + +/** + * The gate in front of locked notes. + * + * Locking is deliberately device-level rather than an in-app passcode: an app + * that invents its own PIN has to store it, and storing it badly is worse than + * not offering the feature. This delegates to whatever the phone already + * trusts -- fingerprint, face, or the device passcode as a fallback. + */ +export interface LockResult { + ok: boolean; + /** Set when the device cannot authenticate at all, rather than refusing to. */ + unavailable?: boolean; +} + +/** True when the device has a biometric or passcode enrolled to check against. */ +export async function canLock(): Promise { + if (Platform.OS === 'web') return false; + return LocalAuthentication.isEnrolledAsync(); +} + +export async function authenticate(reason = 'Unlock your notes'): Promise { + if (!(await canLock())) return { ok: false, unavailable: true }; + + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: reason, + // Without this the OS offers no way through for a user who has a passcode + // but no working fingerprint reader. + disableDeviceFallback: false, + cancelLabel: 'Cancel', + }); + + return { ok: result.success }; +} diff --git a/apps/mobile/src/lib/nav.ts b/apps/mobile/src/lib/nav.ts new file mode 100644 index 0000000..3895d2c --- /dev/null +++ b/apps/mobile/src/lib/nav.ts @@ -0,0 +1,19 @@ +import { useRouter } from 'expo-router'; +import { useCallback } from 'react'; + +/** + * Back, with somewhere to land. + * + * `router.back()` is a no-op when there is no history, which is exactly the + * case whenever a screen is entered directly rather than navigated to: a + * deep link, a share intent, or a reloaded URL in the web build. The back + * button would then do nothing at all, stranding the user on the screen. + * Falling back to the list is what "back" means when there is no back. + */ +export function useGoBack(): () => void { + const router = useRouter(); + return useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace('/'); + }, [router]); +} diff --git a/apps/mobile/src/lib/undo.ts b/apps/mobile/src/lib/undo.ts new file mode 100644 index 0000000..e8f9507 --- /dev/null +++ b/apps/mobile/src/lib/undo.ts @@ -0,0 +1,92 @@ +import { useCallback, useRef, useState } from 'react'; + +export interface History { + value: T; + canUndo: boolean; + canRedo: boolean; + /** Records a new value. Consecutive edits coalesce into one entry. */ + set: (value: T, options?: { discrete?: boolean }) => void; + undo: () => T | null; + redo: () => T | null; + /** Replaces the value without recording history, e.g. when loading a note. */ + reset: (value: T) => void; +} + +/** How long a run of typing keeps folding into a single undo entry. */ +const COALESCE_MS = 700; + +/** + * Undo/redo over one immutable value. + * + * Generic over the value because the editor's content is a block document, not + * a string: every edit already produces a whole new `Block[]`, so snapshots are + * just the values that have been through here. + * + * Typing coalesces so that one Undo does not walk back a character at a time, + * but anything a toolbar does is recorded discretely -- bulleting a line and + * then undoing it should put the bullet back, not eat the word before it. + */ +export function useHistory(initial: T): History { + const [value, setValue] = useState(initial); + + const past = useRef([]); + const future = useRef([]); + const current = useRef(initial); + const lastEntryAt = useRef(0); + + // Depth is mirrored in state so the toolbar's undo/redo buttons re-render + // when they become available; the refs stay the source of truth. + const [depth, setDepth] = useState({ past: 0, future: 0 }); + + const set = useCallback['set']>((next, options) => { + const previous = current.current; + if (Object.is(previous, next)) return; + + const now = Date.now(); + const discrete = options?.discrete === true; + const coalesce = !discrete && now - lastEntryAt.current < COALESCE_MS && past.current.length > 0; + + if (!coalesce) past.current.push(previous); + lastEntryAt.current = discrete ? 0 : now; + + future.current = []; + current.current = next; + setValue(next); + setDepth({ past: past.current.length, future: 0 }); + }, []); + + const undo = useCallback(() => { + const entry = past.current.pop(); + if (entry === undefined) return null; + + future.current.push(current.current); + current.current = entry; + lastEntryAt.current = 0; + setValue(entry); + setDepth({ past: past.current.length, future: future.current.length }); + return entry; + }, []); + + const redo = useCallback(() => { + const entry = future.current.pop(); + if (entry === undefined) return null; + + past.current.push(current.current); + current.current = entry; + lastEntryAt.current = 0; + setValue(entry); + setDepth({ past: past.current.length, future: future.current.length }); + return entry; + }, []); + + const reset = useCallback((next: T) => { + past.current = []; + future.current = []; + current.current = next; + lastEntryAt.current = 0; + setValue(next); + setDepth({ past: 0, future: 0 }); + }, []); + + return { value, canUndo: depth.past > 0, canRedo: depth.future > 0, set, undo, redo, reset }; +} diff --git a/apps/mobile/src/store/notebooks-store.tsx b/apps/mobile/src/store/notebooks-store.tsx new file mode 100644 index 0000000..508ea5a --- /dev/null +++ b/apps/mobile/src/store/notebooks-store.tsx @@ -0,0 +1,112 @@ +import type { Notebook } from '@dailynote/core'; +import { useSQLiteContext } from 'expo-sqlite'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +import { createNotebooksRepo, type NotebooksRepo } from '@/db/notebooks-repo'; + +interface NotebooksContextValue { + notebooks: Notebook[]; + loading: boolean; + repo: NotebooksRepo; + refresh: () => Promise; + create: (name: string, color?: string) => Promise; + rename: (id: string, name: string) => Promise; + recolor: (id: string, color: string) => Promise; + reorder: (ids: readonly string[]) => Promise; + remove: (id: string) => Promise; + /** Looks up a notebook by id; null id (the Default notebook) yields null. */ + find: (id: string | null) => Notebook | null; +} + +const NotebooksContext = createContext(null); + +/** + * Notebooks are few and change rarely, so the whole set is held in memory and + * re-read after each write -- the same approach as the notes store, and for the + * same reason: one source of truth, no cache invalidation to get wrong. + */ +export function NotebooksProvider({ children }: { children: ReactNode }) { + const db = useSQLiteContext(); + const repo = useMemo(() => createNotebooksRepo(db), [db]); + + const [notebooks, setNotebooks] = useState([]); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + setNotebooks(await repo.all()); + }, [repo]); + + useEffect(() => { + let active = true; + repo + .all() + .then((rows) => { + if (active) setNotebooks(rows); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [repo]); + + const value = useMemo( + () => ({ + notebooks, + loading, + repo, + refresh, + + find(id) { + if (id === null) return null; + return notebooks.find((notebook) => notebook.id === id) ?? null; + }, + + async create(name, color) { + const notebook = await repo.create(name, color); + await refresh(); + return notebook; + }, + + async rename(id, name) { + await repo.rename(id, name); + await refresh(); + }, + + async recolor(id, color) { + await repo.recolor(id, color); + await refresh(); + }, + + async reorder(ids) { + await repo.reorder(ids); + await refresh(); + }, + + async remove(id) { + await repo.remove(id); + await refresh(); + }, + }), + [notebooks, loading, repo, refresh], + ); + + return {children}; +} + +export function useNotebooks(): NotebooksContextValue { + const context = useContext(NotebooksContext); + if (context === null) { + throw new Error('useNotebooks must be used inside a '); + } + return context; +} diff --git a/apps/mobile/src/store/notes-store.tsx b/apps/mobile/src/store/notes-store.tsx index 91d8c92..697688b 100644 --- a/apps/mobile/src/store/notes-store.tsx +++ b/apps/mobile/src/store/notes-store.tsx @@ -31,6 +31,14 @@ interface NotesContextValue { setContent: (id: string, blocks: readonly Block[]) => Promise; softDelete: (id: string) => Promise; restore: (id: string) => Promise; + purge: (id: string) => Promise; + purgeAll: () => Promise; + /** Files a note into a notebook; null moves it to the Default notebook. */ + move: (id: string, notebookId: string | null) => Promise; + togglePin: (id: string) => Promise; + setLocked: (id: string, locked: boolean) => Promise; + /** Ticks a to-do off, or puts an already-done one back on the list. */ + setCompleted: (id: string, completed: boolean) => Promise; promote: (id: string) => Promise; review: (id: string, answer: ReviewAnswer, updateBody?: string) => Promise; } @@ -96,6 +104,48 @@ export function NotesProvider({ children }: { children: ReactNode }) { await refresh(); }, + async purge(id) { + await repo.purge(id); + await refresh(); + }, + + async purgeAll() { + await repo.purgeAll(); + await refresh(); + }, + + async move(id, notebookId) { + await repo.update(id, { notebookId }); + await refresh(); + }, + + async togglePin(id) { + const note = await repo.get(id); + if (note === null) return; + await repo.update(id, { + pinnedAt: note.pinnedAt === null ? new Date().toISOString() : null, + }); + await refresh(); + }, + + async setLocked(id, locked) { + await repo.update(id, { locked }); + await refresh(); + }, + + async setCompleted(id, completed) { + const note = await repo.get(id); + if (note === null) return; + // Un-completing puts the note back in the "due now" bucket rather than + // restoring whatever future date it had: if you are reopening it, it + // is because it needs attention now. + await repo.update(id, { + completedAt: completed ? new Date().toISOString() : null, + nextReviewAt: completed ? note.nextReviewAt : new Date().toISOString(), + }); + await refresh(); + }, + async promote(id) { const note = await repo.get(id); if (note === null) return; diff --git a/apps/mobile/src/theme/colors.json b/apps/mobile/src/theme/colors.json index b983027..b72a25f 100644 --- a/apps/mobile/src/theme/colors.json +++ b/apps/mobile/src/theme/colors.json @@ -1,10 +1,15 @@ { - "paper": { "light": "#faf9f5", "dark": "#121110" }, - "canvas": { "light": "#f1efe9", "dark": "#0d0c0b" }, - "card": { "light": "#fffdf9", "dark": "#1c1a17" }, - "ink": { "light": "#211f1c", "dark": "#ece9e2" }, - "muted": { "light": "#6f6a60", "dark": "#98928a" }, - "faint": { "light": "#aaa49a", "dark": "#5c574f" }, - "line": { "light": "#e8e5dc", "dark": "#2a2723" }, - "accent": { "light": "#31684d", "dark": "#a4c8b2" } + "canvas": { "light": "#f7f5f0", "dark": "#000000" }, + "paper": { "light": "#ffffff", "dark": "#000000" }, + "card": { "light": "#ffffff", "dark": "#1a1918" }, + "elevated": { "light": "#ebe8e1", "dark": "#2b2a28" }, + "sheet": { "light": "#ffffff", "dark": "#1c1b1a" }, + "ink": { "light": "#16150f", "dark": "#f5f4f2" }, + "muted": { "light": "#5f5a51", "dark": "#9a9791" }, + "faint": { "light": "#8d887e", "dark": "#6a6763" }, + "line": { "light": "#e2ded4", "dark": "#302f2c" }, + "accent": { "light": "#b0740a", "dark": "#f0a92b" }, + "accentInk": { "light": "#ffffff", "dark": "#141310" }, + "swatch": { "light": "#e8b84b", "dark": "#f2d081" }, + "swatchWarm": { "light": "#e09455", "dark": "#f0b27a" } } diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts index 4b71c6a..8697495 100644 --- a/apps/mobile/src/theme/index.ts +++ b/apps/mobile/src/theme/index.ts @@ -1,28 +1,46 @@ +import { useMemo } from 'react'; import { useColorScheme } from 'react-native'; import palette from './colors.json'; export { palette }; +type PaletteKey = keyof typeof palette; + +export type Theme = { dark: boolean } & Record; + +const KEYS = Object.keys(palette) as PaletteKey[]; + +function resolve(dark: boolean): Theme { + const entries = KEYS.map((key) => [key, dark ? palette[key].dark : palette[key].light]); + return { dark, ...Object.fromEntries(entries) } as Theme; +} + +// Both schemes are resolved once at module load: the object identity is stable, +// so a theme value in a dependency array does not retrigger effects on render. +const THEMES = { light: resolve(false), dark: resolve(true) } as const; + /** * The palette, resolved for the current colour scheme. * * NativeWind classes cover most styling, but React Native props that take a * colour value (`selectionColor`, `placeholderTextColor`, navigator header - * options) need the actual hex — this keeps them on the same palette as the - * classes instead of scattering hardcoded copies around. + * options, icon glyph fills) need the actual hex — this keeps them on the same + * palette as the classes instead of scattering hardcoded copies around. */ -export function useTheme() { +export function useTheme(): Theme { const dark = useColorScheme() === 'dark'; - return { - dark, - paper: dark ? palette.paper.dark : palette.paper.light, - canvas: dark ? palette.canvas.dark : palette.canvas.light, - card: dark ? palette.card.dark : palette.card.light, - ink: dark ? palette.ink.dark : palette.ink.light, - muted: dark ? palette.muted.dark : palette.muted.light, - faint: dark ? palette.faint.dark : palette.faint.light, - line: dark ? palette.line.dark : palette.line.light, - accent: dark ? palette.accent.dark : palette.accent.light, - } as const; + return useMemo(() => (dark ? THEMES.dark : THEMES.light), [dark]); +} + +/** + * Resolves a stored palette key, such as a notebook's colour. + * + * Notebooks store a key rather than a hex value so they re-theme with the app, + * but the key comes out of the database and may name a colour that a later + * release removed -- hence the fallback rather than an index straight into the + * theme. + */ +export function swatchColor(theme: Theme, key: string): string { + return key in palette ? theme[key as PaletteKey] : theme.swatch; } diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js index 5ee058e..994bc66 100644 --- a/apps/mobile/tailwind.config.js +++ b/apps/mobile/tailwind.config.js @@ -1,29 +1,39 @@ const palette = require('./src/theme/colors.json'); +/** One palette entry -> a Tailwind colour with a `-dark` shade. */ +const shade = (name) => ({ DEFAULT: palette[name].light, dark: palette[name].dark }); + /** @type {import('tailwindcss').Config} */ module.exports = { content: ['./src/**/*.{js,jsx,ts,tsx}'], presets: [require('nativewind/preset')], // 'class' instead of the default 'media' because NativeWind's web runtime - // crashes in dev under 'media' ("Cannot manually set color scheme"). - // Native is unaffected -- it follows the system scheme either way -- and - // the web class is synced to the OS setting in app/_layout.tsx. + // crashes in dev under 'media' ("Cannot manually set color scheme"). The + // class is synced to the OS setting on every platform in app/_layout.tsx -- + // native included, or `dark:` variants never activate there at all. darkMode: 'class', theme: { extend: { colors: { - // Paper, not chrome. A notes app should feel like a surface you write - // on, so the palette is warm near-monochrome with one quiet accent - // reserved for commitments. Hex values live in src/theme/colors.json, - // shared with runtime code via src/theme. - paper: { DEFAULT: palette.paper.light, dark: palette.paper.dark }, - canvas: { DEFAULT: palette.canvas.light, dark: palette.canvas.dark }, - card: { DEFAULT: palette.card.light, dark: palette.card.dark }, - ink: { DEFAULT: palette.ink.light, dark: palette.ink.dark }, - muted: { DEFAULT: palette.muted.light, dark: palette.muted.dark }, - faint: { DEFAULT: palette.faint.light, dark: palette.faint.dark }, - line: { DEFAULT: palette.line.light, dark: palette.line.dark }, - accent: { DEFAULT: palette.accent.light, dark: palette.accent.dark }, + // Dark-first, OLED black with a single amber accent. The canvas is + // true black so cards read as objects lifted off it; anything that + // needs to sit above a card goes on `elevated`. Hex values live in + // src/theme/colors.json, shared with runtime code via src/theme. + canvas: shade('canvas'), + paper: shade('paper'), + card: shade('card'), + elevated: shade('elevated'), + sheet: shade('sheet'), + ink: shade('ink'), + muted: shade('muted'), + faint: shade('faint'), + line: shade('line'), + accent: shade('accent'), + // Text/icons drawn on top of an accent fill. + 'accent-ink': shade('accentInk'), + // Notebook folder swatches. + swatch: shade('swatch'), + 'swatch-warm': shade('swatchWarm'), }, fontFamily: { // Newsreader carries the writing surface; UI chrome stays on the diff --git a/package-lock.json b/package-lock.json index 67d2e0e..c0e68b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@dailynote/core": "*", "@expo-google-fonts/newsreader": "^0.4.1", "@expo/ui": "~57.0.8", + "@expo/vector-icons": "^15.0.2", "expo": "~57.0.8", "expo-constants": "~57.0.7", "expo-crypto": "~57.0.1", @@ -39,6 +40,7 @@ "expo-image": "~57.0.1", "expo-image-picker": "~57.0.7", "expo-linking": "~57.0.4", + "expo-local-authentication": "~57.0.2", "expo-router": "~57.0.9", "expo-splash-screen": "~57.0.5", "expo-sqlite": "~57.0.1", @@ -2905,6 +2907,17 @@ } } }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/ws-tunnel": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", @@ -7875,6 +7888,18 @@ "react-native": "*" } }, + "node_modules/expo-local-authentication": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-57.0.2.tgz", + "integrity": "sha512-8K4zcrQ5wZkRS1rwEWY8qHbeGVMNh35e9D1VTVKlMHz1O47itW+pW6iBVuqwGFLozN4fRBeEDQH8hu9Gt58YuQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz", diff --git a/packages/core/src/note.ts b/packages/core/src/note.ts index 491143f..c73a9dd 100644 --- a/packages/core/src/note.ts +++ b/packages/core/src/note.ts @@ -20,6 +20,10 @@ export function createNote(input: NewNoteInput, now: Date): Note { title: deriveTitle(input.body), doc: input.doc ?? null, + notebookId: input.notebookId ?? null, + locked: false, + pinnedAt: null, + rootId: input.rootId ?? input.id, parentId: input.parentId ?? null, kind: input.kind ?? 'note', diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2aef558..5725c10 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -29,6 +29,14 @@ export interface Note { */ doc: string | null; + // --- filing --- + /** Null means the Default notebook, which is the absence of a notebook. */ + notebookId: string | null; + /** Hidden from the list until the device lock is satisfied. */ + locked: boolean; + /** Non-null sorts the note above unpinned ones, newest pin first. */ + pinnedAt: Timestamp | null; + // --- threading / follow-through --- /** Equals `id` for a root note. A thread is a root plus its updates. */ rootId: string; @@ -75,4 +83,34 @@ export interface NewNoteInput { rootId?: string; parentId?: string | null; kind?: NoteKind; + notebookId?: string | null; +} + +/** + * A user-created folder for notes. + * + * There is deliberately no row for the Default notebook: a note with a null + * `notebookId` is in it. That keeps the fallback bucket undeletable and avoids + * a per-install id to reconcile when sync lands. + */ +export interface Notebook { + id: string; + userId: string | null; + name: string; + /** A palette key (see the mobile theme), never a hex value. */ + color: string; + sortOrder: number; + + createdAt: Timestamp; + updatedAt: Timestamp; + deleted: boolean; } + +/** The sentinel the UI uses for "everything", distinct from a real id. */ +export const ALL_NOTES = '__all__'; + +/** The sentinel for the null-notebookId bucket. */ +export const DEFAULT_NOTEBOOK = '__default__'; + +/** What the notes list is currently filtered to. */ +export type NotebookFilter = string;