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;