From 868f9dccbf1a26549f7e8c284b410affc58ebe7b Mon Sep 17 00:00:00 2001 From: Kshitij Bachhav Date: Tue, 4 Aug 2026 17:25:50 +0530 Subject: [PATCH 1/3] notes: black/amber palette, notebooks schema, formatting engine The app rendered as a flat black field on device for three reasons, all fixed here: - The dark palette had no internal contrast -- canvas #0d0c0b, paper #121110, card #1c1a17 and line #2a2723 are four indistinguishable near-blacks, so cards never separated from the background and borders were invisible. Replaced with an OLED-black canvas, a lifted card, and a single amber accent. - darkMode 'class' was only synced to NativeWind on web, so on device every `dark:` variant silently no-opped while useTheme() went dark anyway -- dark navigator chrome behind light-styled content. - SystemUI now paints the window behind the React root, so there is no white flash between the splash tearing down and the first frame. Also lays the groundwork for the reference feature set: - Migration v2 adds notebooks, notes.notebook_id, locked and pinned_at. A note with a null notebook_id is in the Default notebook; modelling the default as the absence of a row keeps it undeletable and leaves no per-install id to reconcile when sync lands. - @dailynote/core/formatting: pure string transforms for block styles, lists, checklists, indentation, renumbering, list continuation on Enter and inline marks. A TextInput renders one uniform text style, so the body stays plain text carrying Markdown-ish markers -- the same way the reference editor shows literal 1. / 2. / 3. as you type. 69 tests, since none of this is verifiable on a simulator. Co-Authored-By: Claude Opus 5 --- apps/mobile/package.json | 1 + apps/mobile/src/app/_layout.tsx | 73 ++-- apps/mobile/src/db/migrations.ts | 35 ++ apps/mobile/src/db/notebooks-repo.ts | 132 +++++++ apps/mobile/src/db/notes-repo.ts | 36 +- apps/mobile/src/store/notebooks-store.tsx | 112 ++++++ apps/mobile/src/store/notes-store.tsx | 35 ++ apps/mobile/src/theme/colors.json | 21 +- apps/mobile/src/theme/index.ts | 34 +- apps/mobile/tailwind.config.js | 40 +- package-lock.json | 12 + packages/core/src/formatting.test.ts | 374 +++++++++++++++++++ packages/core/src/formatting.ts | 435 ++++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/note.ts | 4 + packages/core/src/title.ts | 5 +- packages/core/src/types.ts | 38 ++ 17 files changed, 1315 insertions(+), 73 deletions(-) create mode 100644 apps/mobile/src/db/notebooks-repo.ts create mode 100644 apps/mobile/src/store/notebooks-store.tsx create mode 100644 packages/core/src/formatting.test.ts create mode 100644 packages/core/src/formatting.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c973df7..854bfca 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", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 15b326a..15b9027 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,46 @@ 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 +93,8 @@ export default function RootLayout() { function Booting() { const theme = useTheme(); return ( - - + + ); } diff --git a/apps/mobile/src/db/migrations.ts b/apps/mobile/src/db/migrations.ts index f501e70..6a13706 100644 --- a/apps/mobile/src/db/migrations.ts +++ b/apps/mobile/src/db/migrations.ts @@ -51,6 +51,41 @@ const MIGRATIONS: readonly ((db: SQLiteDatabase) => Promise)[] = [ create index notes_root_idx on notes (root_id); `); }, + + // v2 -- notebooks + // + // 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 c3c7f2c..1fe52c3 100644 --- a/apps/mobile/src/db/notes-repo.ts +++ b/apps/mobile/src/db/notes-repo.ts @@ -13,12 +13,17 @@ 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; setBody(id: string, body: string): Promise; softDelete(id: string): Promise; restore(id: string): Promise; + /** Irreversible. Only reachable from Recently deleted. */ + purge(id: string): Promise; + purgeAll(): Promise; } export interface CreateOptions { @@ -28,6 +33,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. */ @@ -40,6 +46,9 @@ interface NoteRow { user_id: string | null; body: string; title: string | null; + notebook_id: string | null; + locked: number; + pinned_at: string | null; root_id: string; parent_id: string | null; kind: string; @@ -64,6 +73,9 @@ function toNote(row: NoteRow): Note { userId: row.user_id, body: row.body, title: row.title, + 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'], @@ -89,6 +101,9 @@ const COLUMNS: Record = { userId: 'user_id', body: 'body', title: 'title', + notebookId: 'notebook_id', + locked: 'locked', + pinnedAt: 'pinned_at', rootId: 'root_id', parentId: 'parent_id', kind: 'kind', @@ -116,8 +131,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); }, @@ -177,5 +203,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/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 1fdc2d0..972f272 100644 --- a/apps/mobile/src/store/notes-store.tsx +++ b/apps/mobile/src/store/notes-store.tsx @@ -30,6 +30,12 @@ interface NotesContextValue { setBody: (id: string, body: string) => 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; promote: (id: string) => Promise; review: (id: string, answer: ReviewAnswer, updateBody?: string) => Promise; } @@ -90,6 +96,35 @@ 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 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..ed4e77f 100644 --- a/apps/mobile/src/theme/index.ts +++ b/apps/mobile/src/theme/index.ts @@ -1,28 +1,34 @@ +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]); } 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 32221a5..1f62861 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", @@ -2902,6 +2903,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", diff --git a/packages/core/src/formatting.test.ts b/packages/core/src/formatting.test.ts new file mode 100644 index 0000000..79577c3 --- /dev/null +++ b/packages/core/src/formatting.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyBlockStyle, + applyListStyle, + blockStyleAt, + changeIndent, + characterCount, + continueList, + formatLine, + hasInlineMark, + listStyleAt, + parseLine, + renumber, + toggleChecked, + toggleInlineMark, + toPlainText, +} from './formatting'; + +/** Marks the caret in a fixture with `|`, so tests read like the editor. */ +function at(fixture: string) { + const start = fixture.indexOf('|'); + const end = fixture.indexOf('|', start + 1); + if (end === -1) { + return { text: fixture.replace('|', ''), selection: { start, end: start } }; + } + return { + text: fixture.replace(/\|/g, ''), + selection: { start, end: end - 1 }, + }; +} + +/** Renders an Edit back into the `|`-marked form for comparison. */ +function show(edit: { text: string; selection: { start: number; end: number } }) { + const { text, selection } = edit; + if (selection.start === selection.end) { + return `${text.slice(0, selection.start)}|${text.slice(selection.start)}`; + } + return `${text.slice(0, selection.start)}|${text.slice(selection.start, selection.end)}|${text.slice(selection.end)}`; +} + +describe('parseLine', () => { + it('reads a plain line as body text', () => { + expect(parseLine('hello')).toMatchObject({ + indent: 0, + style: 'body', + list: 'none', + content: 'hello', + }); + }); + + it.each([ + ['# Title', 'title'], + ['## Subtitle', 'subtitle'], + ['### Heading', 'heading'], + ['> Note', 'note'], + ])('reads %s as %s', (line, style) => { + expect(parseLine(line).style).toBe(style); + }); + + it('reads a bullet', () => { + expect(parseLine('- milk')).toMatchObject({ list: 'bullet', content: 'milk' }); + }); + + it('reads a numbered item and keeps its number', () => { + expect(parseLine('3. third')).toMatchObject({ list: 'numbered', number: 3, content: 'third' }); + }); + + it.each([ + ['- [ ] open', false], + ['- [x] done', true], + ['- [X] done', true], + ])('reads the checkbox in %s', (line, checked) => { + expect(parseLine(line)).toMatchObject({ list: 'checklist', checked }); + }); + + it('prefers checklist over bullet, since a checkbox also starts with "- "', () => { + expect(parseLine('- [ ] task').list).toBe('checklist'); + }); + + it('counts indentation in two-space units', () => { + expect(parseLine(' - deep').indent).toBe(2); + }); + + it('caps indentation at the maximum nesting level', () => { + expect(parseLine(`${' '.repeat(40)}- far`).indent).toBe(4); + }); + + it('round-trips through formatLine', () => { + for (const line of [' - [x] done', '### Heading', '2. second', '> quiet', 'plain']) { + expect(formatLine(parseLine(line))).toBe(line); + } + }); +}); + +describe('applyBlockStyle', () => { + it('adds the marker and keeps the caret on its character', () => { + const { text, selection } = at('hel|lo'); + expect(show(applyBlockStyle(text, selection, 'heading'))).toBe('### hel|lo'); + }); + + it('toggles back to body when the style is already applied', () => { + const { text, selection } = at('### hel|lo'); + expect(show(applyBlockStyle(text, selection, 'heading'))).toBe('hel|lo'); + }); + + it('swaps one heading level for another', () => { + const { text, selection } = at('# Ti|tle'); + expect(applyBlockStyle(text, selection, 'subtitle').text).toBe('## Title'); + }); + + it('clears a list marker, since a bulleted heading reads as neither', () => { + const { text, selection } = at('- ite|m'); + expect(applyBlockStyle(text, selection, 'heading').text).toBe('### item'); + }); + + it('styles every line the selection touches', () => { + const { text, selection } = at('|one\ntwo|'); + expect(applyBlockStyle(text, selection, 'subtitle').text).toBe('## one\n## two'); + }); + + it('leaves lines outside the selection alone', () => { + const { text, selection } = at('before\non|e\nafter'); + expect(applyBlockStyle(text, selection, 'title').text).toBe('before\n# one\nafter'); + }); + + it('never lets the caret fall in front of the marker', () => { + const { text, selection } = at('|hello'); + expect(show(applyBlockStyle(text, selection, 'title'))).toBe('# |hello'); + }); +}); + +describe('blockStyleAt', () => { + it('reports the shared style of a multi-line selection', () => { + const { text, selection } = at('|## one\n## two|'); + expect(blockStyleAt(text, selection)).toBe('subtitle'); + }); + + it('falls back to body when the lines disagree', () => { + const { text, selection } = at('|# one\n## two|'); + expect(blockStyleAt(text, selection)).toBe('body'); + }); +}); + +describe('applyListStyle', () => { + it('bullets the current line', () => { + const { text, selection } = at('mi|lk'); + expect(show(applyListStyle(text, selection, 'bullet'))).toBe('- mi|lk'); + }); + + it('toggles the list off when reapplied', () => { + const { text, selection } = at('- mi|lk'); + expect(applyListStyle(text, selection, 'bullet').text).toBe('milk'); + }); + + it('numbers a selection from one', () => { + const { text, selection } = at('|one\ntwo\nthree|'); + expect(applyListStyle(text, selection, 'numbered').text).toBe('1. one\n2. two\n3. three'); + }); + + it('converts bullets to a checklist, keeping the text', () => { + const { text, selection } = at('|- one\n- two|'); + expect(applyListStyle(text, selection, 'checklist').text).toBe('- [ ] one\n- [ ] two'); + }); + + it('preserves ticks when a checklist is re-applied over itself', () => { + const { text, selection } = at('|- [x] one\n- [ ] two|'); + expect(applyListStyle(text, selection, 'bullet').text).toBe('- one\n- two'); + }); + + it('drops the heading marker, matching applyBlockStyle in reverse', () => { + const { text, selection } = at('### hea|ding'); + expect(applyListStyle(text, selection, 'bullet').text).toBe('- heading'); + }); +}); + +describe('listStyleAt', () => { + it('reports a uniform list style', () => { + const { text, selection } = at('|- one\n- two|'); + expect(listStyleAt(text, selection)).toBe('bullet'); + }); + + it('reports none when the lines disagree', () => { + const { text, selection } = at('|- one\n1. two|'); + expect(listStyleAt(text, selection)).toBe('none'); + }); +}); + +describe('toggleChecked', () => { + it('ticks an open box', () => { + expect(toggleChecked('- [ ] task', 3).text).toBe('- [x] task'); + }); + + it('unticks a ticked box', () => { + expect(toggleChecked('- [x] task', 3).text).toBe('- [ ] task'); + }); + + it('ticks only the line the offset is on', () => { + const text = '- [ ] one\n- [ ] two'; + expect(toggleChecked(text, 12).text).toBe('- [ ] one\n- [x] two'); + }); + + it('does nothing on a line that is not a checklist item', () => { + expect(toggleChecked('- bullet', 3).text).toBe('- bullet'); + }); +}); + +describe('changeIndent', () => { + it('indents by one level', () => { + const { text, selection } = at('- it|em'); + expect(changeIndent(text, selection, 1).text).toBe(' - item'); + }); + + it('outdents by one level', () => { + const { text, selection } = at(' - it|em'); + expect(changeIndent(text, selection, -1).text).toBe('- item'); + }); + + it('will not outdent past the left margin', () => { + const { text, selection } = at('- it|em'); + expect(changeIndent(text, selection, -1).text).toBe('- item'); + }); + + it('will not indent past the nesting cap', () => { + const { text, selection } = at(`${' '.repeat(4)}- it|em`); + expect(changeIndent(text, selection, 1).text).toBe(`${' '.repeat(4)}- item`); + }); + + it('renumbers so a nested run starts at one again', () => { + const { text, selection } = at('1. one\n2. t|wo'); + expect(changeIndent(text, selection, 1).text).toBe('1. one\n 1. two'); + }); +}); + +describe('renumber', () => { + it('fixes a run that is all ones', () => { + expect(renumber('1. a\n1. b\n1. c')).toBe('1. a\n2. b\n3. c'); + }); + + it('restarts after a blank line', () => { + expect(renumber('1. a\n2. b\n\n1. x\n1. y')).toBe('1. a\n2. b\n\n1. x\n2. y'); + }); + + it('numbers each indent level independently', () => { + expect(renumber('1. a\n 1. x\n 1. y\n1. b')).toBe('1. a\n 1. x\n 2. y\n2. b'); + }); + + it('restarts a nested run after returning to the outer level', () => { + expect(renumber('1. a\n 1. x\n1. b\n 1. y')).toBe('1. a\n 1. x\n2. b\n 1. y'); + }); + + it('leaves bullets and plain lines untouched', () => { + expect(renumber('- a\ntext\n- b')).toBe('- a\ntext\n- b'); + }); +}); + +describe('continueList', () => { + it('returns null outside a list, so Enter inserts a plain newline', () => { + const { text, selection } = at('plain|'); + expect(continueList(text, selection)).toBeNull(); + }); + + it('continues a bullet', () => { + const { text, selection } = at('- milk|'); + expect(show(continueList(text, selection)!)).toBe('- milk\n- |'); + }); + + it('continues a numbered list with the next number', () => { + const { text, selection } = at('1. one\n2. two|'); + expect(show(continueList(text, selection)!)).toBe('1. one\n2. two\n3. |'); + }); + + it('continues a checklist with an unticked box', () => { + const { text, selection } = at('- [x] done|'); + expect(show(continueList(text, selection)!)).toBe('- [x] done\n- [ ] |'); + }); + + it('keeps the indent of the item it continues', () => { + const { text, selection } = at(' - deep|'); + expect(show(continueList(text, selection)!)).toBe(' - deep\n - |'); + }); + + it('outdents an empty nested item rather than adding another', () => { + const { text, selection } = at('- one\n - |'); + expect(show(continueList(text, selection)!)).toBe('- one\n- |'); + }); + + it('ends the list on an empty top-level item', () => { + const { text, selection } = at('- one\n- |'); + expect(show(continueList(text, selection)!)).toBe('- one\n|'); + }); + + it('renumbers what follows when splitting a numbered list', () => { + const { text, selection } = at('1. one|\n2. two'); + expect(continueList(text, selection)!.text).toBe('1. one\n2. \n3. two'); + }); + + it('returns null for a range selection, which Enter should replace', () => { + const { text, selection } = at('- |one|'); + expect(continueList(text, selection)).toBeNull(); + }); +}); + +describe('toggleInlineMark', () => { + it('wraps a selection', () => { + const { text, selection } = at('say |hello| there'); + expect(toggleInlineMark(text, selection, 'bold').text).toBe('say **hello** there'); + }); + + it('selects the wrapped run so a second tap undoes it', () => { + const { text, selection } = at('say |hello| there'); + const bolded = toggleInlineMark(text, selection, 'bold'); + expect(toggleInlineMark(bolded.text, bolded.selection, 'bold').text).toBe('say hello there'); + }); + + it('unwraps when the markers hug the selection', () => { + const { text, selection } = at('say **|hello|** there'); + expect(toggleInlineMark(text, selection, 'bold').text).toBe('say hello there'); + }); + + it('puts the caret between the markers when nothing is selected', () => { + const { text, selection } = at('say |'); + expect(show(toggleInlineMark(text, selection, 'bold'))).toBe('say **|**'); + }); + + it.each([ + ['bold', '**x**'], + ['italic', '*x*'], + ['underline', 'x'], + ['strikethrough', '~~x~~'], + ] as const)('wraps %s correctly', (mark, expected) => { + const { text, selection } = at('|x|'); + expect(toggleInlineMark(text, selection, mark).text).toBe(expected); + }); +}); + +describe('hasInlineMark', () => { + it('sees markers hugging the selection', () => { + const { text, selection } = at('**|bold|**'); + expect(hasInlineMark(text, selection, 'bold')).toBe(true); + }); + + it('sees markers inside the selection', () => { + const { text, selection } = at('|**bold**|'); + expect(hasInlineMark(text, selection, 'bold')).toBe(true); + }); + + it('is false for unmarked text', () => { + const { text, selection } = at('|plain|'); + expect(hasInlineMark(text, selection, 'bold')).toBe(false); + }); +}); + +describe('toPlainText', () => { + it('strips block markers', () => { + expect(toPlainText('# Title\n- [ ] task\n1. one')).toBe('Title\ntask\none'); + }); + + it('strips inline markers', () => { + expect(toPlainText('**bold** and *italic* and ~~gone~~ and under')).toBe( + 'bold and italic and gone and under', + ); + }); +}); + +describe('characterCount', () => { + it('counts content without markers or whitespace', () => { + expect(characterCount('# Title\n- [ ] a b')).toBe(7); + }); + + it('is zero for an empty note', () => { + expect(characterCount('')).toBe(0); + }); +}); diff --git a/packages/core/src/formatting.ts b/packages/core/src/formatting.ts new file mode 100644 index 0000000..304e017 --- /dev/null +++ b/packages/core/src/formatting.ts @@ -0,0 +1,435 @@ +/** + * Block and inline formatting over a plain-text note body. + * + * A React Native `TextInput` renders one uniform text style, so a WYSIWYG rich + * text model is not available to us without embedding a web view. Instead the + * body stays plain text carrying Markdown-ish markers -- `## `, `- `, `1. `, + * `- [ ] `, `**bold**` -- exactly the way the reference editor shows literal + * `1.` / `2.` / `3.` markers as you type a numbered list. + * + * Everything here is a pure string transform so the whole toolbar is testable + * without a simulator. Each function takes the body plus the current selection + * and returns the new body plus where the selection should end up, because a + * toolbar that moves the caret to the wrong place feels broken even when the + * text is right. + */ + +/** Paragraph-level style, mapped to the reference's Title/Subtitle/... chips. */ +export type BlockStyle = 'title' | 'subtitle' | 'heading' | 'body' | 'note'; + +/** List decoration, independent of `BlockStyle`. */ +export type ListStyle = 'none' | 'bullet' | 'numbered' | 'checklist'; + +/** Whole-note text alignment. A TextInput cannot align lines individually. */ +export type Align = 'left' | 'center' | 'right'; + +export type InlineMark = 'bold' | 'italic' | 'underline' | 'strikethrough'; + +/** A caret (start === end) or a range, in UTF-16 offsets into the body. */ +export interface Selection { + start: number; + end: number; +} + +/** The result of every transform: new text, and where to put the caret. */ +export interface Edit { + text: string; + selection: Selection; +} + +/** One nesting level of indent. Two spaces keeps deep lists readable. */ +export const INDENT_UNIT = ' '; + +/** Beyond this, indentation eats the whole line width on a phone. */ +export const MAX_INDENT = 4; + +const HEADING_MARKERS: Record, string> = { + title: '# ', + subtitle: '## ', + heading: '### ', + note: '> ', +}; + +const INLINE_MARKERS: Record = { + bold: ['**', '**'], + italic: ['*', '*'], + underline: ['', ''], + strikethrough: ['~~', '~~'], +}; + +/** A line broken into its markers and its text. */ +export interface ParsedLine { + /** Nesting level, in `INDENT_UNIT`s. */ + indent: number; + style: BlockStyle; + list: ListStyle; + /** For `list === 'numbered'`, the number actually written on the line. */ + number: number; + /** For `list === 'checklist'`, whether the box is ticked. */ + checked: boolean; + /** The line with every marker stripped. */ + content: string; +} + +const CHECKLIST_RE = /^- \[([ xX])\] ?/; +const BULLET_RE = /^[-*] /; +const NUMBERED_RE = /^(\d+)\. /; +const HEADING_RE = /^(#{1,3}) /; +const NOTE_RE = /^> ?/; + +/** Splits one line into indent, block style, list marker and content. */ +export function parseLine(line: string): ParsedLine { + const leading = /^ */.exec(line)?.[0].length ?? 0; + const indent = Math.min(Math.floor(leading / INDENT_UNIT.length), MAX_INDENT); + let rest = line.slice(leading); + + let style: BlockStyle = 'body'; + const heading = HEADING_RE.exec(rest); + if (heading !== null) { + const level = heading[1]!.length; + style = level === 1 ? 'title' : level === 2 ? 'subtitle' : 'heading'; + rest = rest.slice(heading[0].length); + } else if (NOTE_RE.test(rest)) { + style = 'note'; + rest = rest.replace(NOTE_RE, ''); + } + + let list: ListStyle = 'none'; + let number = 0; + let checked = false; + + // Checklist first: `- [ ] x` also matches the bullet pattern. + const check = CHECKLIST_RE.exec(rest); + const numbered = NUMBERED_RE.exec(rest); + if (check !== null) { + list = 'checklist'; + checked = check[1]!.toLowerCase() === 'x'; + rest = rest.slice(check[0].length); + } else if (BULLET_RE.test(rest)) { + list = 'bullet'; + rest = rest.replace(BULLET_RE, ''); + } else if (numbered !== null) { + list = 'numbered'; + number = Number.parseInt(numbered[1]!, 10); + rest = rest.slice(numbered[0].length); + } + + return { indent, style, list, number, checked, content: rest }; +} + +/** The inverse of `parseLine`. */ +export function formatLine(line: ParsedLine): string { + const indent = INDENT_UNIT.repeat(Math.min(Math.max(line.indent, 0), MAX_INDENT)); + const style = line.style === 'body' ? '' : HEADING_MARKERS[line.style]; + const list = + line.list === 'bullet' + ? '- ' + : line.list === 'numbered' + ? `${Math.max(line.number, 1)}. ` + : line.list === 'checklist' + ? `- [${line.checked ? 'x' : ' '}] ` + : ''; + return `${indent}${style}${list}${line.content}`; +} + +/** The marker prefix a line carries, i.e. everything before its content. */ +export function markerOf(line: ParsedLine): string { + return formatLine({ ...line, content: '' }); +} + +// --- line addressing ------------------------------------------------------- + +/** Offset of the start of the line containing `offset`. */ +function lineStart(text: string, offset: number): number { + return text.lastIndexOf('\n', Math.max(offset - 1, 0)) + 1; +} + +/** Offset of the end of the line containing `offset`, excluding the newline. */ +function lineEnd(text: string, offset: number): number { + const index = text.indexOf('\n', offset); + return index === -1 ? text.length : index; +} + +/** The full-line span covering every line the selection touches. */ +export function lineSpan(text: string, selection: Selection): Selection { + const start = Math.min(selection.start, selection.end); + const end = Math.max(selection.start, selection.end); + return { start: lineStart(text, start), end: lineEnd(text, end) }; +} + +/** + * Rewrites every line the selection touches, preserving the selection as best + * it can: the caret keeps its offset within its own line, so re-styling the + * line the user is typing on does not throw them back to the start of it. + */ +function mapLines( + text: string, + selection: Selection, + transform: (line: ParsedLine, index: number) => ParsedLine, +): Edit { + const span = lineSpan(text, selection); + const before = text.slice(0, span.start); + const after = text.slice(span.end); + const lines = text.slice(span.start, span.end).split('\n'); + + const firstMarkerBefore = markerOf(parseLine(lines[0] ?? '')).length; + const rewritten = lines.map((line, index) => formatLine(transform(parseLine(line), index))); + const firstMarkerAfter = markerOf(parseLine(rewritten[0] ?? '')).length; + + const shift = firstMarkerAfter - firstMarkerBefore; + const body = rewritten.join('\n'); + const next = `${before}${body}${after}`; + + // Keep the caret glued to the character it was on, but never let it slide + // in front of the marker it now sits behind. + const clamp = (offset: number) => { + if (offset < span.start) return offset; + const moved = offset + shift; + return Math.max(span.start + firstMarkerAfter, Math.min(moved, before.length + body.length)); + }; + + return { + text: next, + selection: { start: clamp(selection.start), end: clamp(selection.end) }, + }; +} + +// --- block style ----------------------------------------------------------- + +/** The style shared by every selected line, or 'body' when they disagree. */ +export function blockStyleAt(text: string, selection: Selection): BlockStyle { + const span = lineSpan(text, selection); + const styles = text + .slice(span.start, span.end) + .split('\n') + .map((line) => parseLine(line).style); + const first = styles[0] ?? 'body'; + return styles.every((style) => style === first) ? first : 'body'; +} + +/** + * Applies a paragraph style. Re-applying the current style reverts to body, so + * the chips behave as toggles the way the reference's do. + */ +export function applyBlockStyle(text: string, selection: Selection, style: BlockStyle): Edit { + const current = blockStyleAt(text, selection); + const next = current === style ? 'body' : style; + // A heading and a list marker on one line reads as neither, so taking a + // paragraph style clears the list, and vice versa. + return mapLines(text, selection, (line) => ({ + ...line, + style: next, + list: next === 'body' ? line.list : 'none', + })); +} + +// --- lists ----------------------------------------------------------------- + +/** The list style shared by every selected line, or 'none' when they disagree. */ +export function listStyleAt(text: string, selection: Selection): ListStyle { + const span = lineSpan(text, selection); + const styles = text + .slice(span.start, span.end) + .split('\n') + .map((line) => parseLine(line).list); + const first = styles[0] ?? 'none'; + return styles.every((style) => style === first) ? first : 'none'; +} + +/** Applies a list style, toggling back to no list when already applied. */ +export function applyListStyle(text: string, selection: Selection, list: ListStyle): Edit { + const current = listStyleAt(text, selection); + const next = current === list ? 'none' : list; + const edit = mapLines(text, selection, (line, index) => ({ + ...line, + list: next, + number: index + 1, + checked: next === 'checklist' ? line.checked : false, + style: next === 'none' ? line.style : 'body', + })); + return { ...edit, text: renumber(edit.text) }; +} + +/** Ticks or unticks the checklist item on the line containing `offset`. */ +export function toggleChecked(text: string, offset: number): Edit { + const start = lineStart(text, offset); + const end = lineEnd(text, offset); + const line = parseLine(text.slice(start, end)); + if (line.list !== 'checklist') return { text, selection: { start: offset, end: offset } }; + + const rewritten = formatLine({ ...line, checked: !line.checked }); + return { + text: `${text.slice(0, start)}${rewritten}${text.slice(end)}`, + selection: { start: offset, end: offset }, + }; +} + +/** Shifts the selected lines one nesting level in or out. */ +export function changeIndent(text: string, selection: Selection, delta: number): Edit { + const edit = mapLines(text, selection, (line) => ({ + ...line, + indent: Math.min(Math.max(line.indent + delta, 0), MAX_INDENT), + })); + return { ...edit, text: renumber(edit.text) }; +} + +/** + * Rewrites the numbers on ordered lists so they read 1, 2, 3. + * + * Numbering restarts per indent level and per run: a blank line, a + * differently-styled line, or an outdent all begin a new list. + */ +export function renumber(text: string): string { + const counters = new Map(); + let previousIndent: number | null = null; + + return text + .split('\n') + .map((raw) => { + const line = parseLine(raw); + + if (line.list !== 'numbered') { + // A blank line ends every open list; a non-numbered line of content + // only ends the lists nested at or below it. + if (line.content.trim().length === 0 && line.list === 'none') counters.clear(); + previousIndent = null; + return raw; + } + + if (previousIndent !== null && line.indent < previousIndent) { + for (const level of [...counters.keys()]) { + if (level > line.indent) counters.delete(level); + } + } + + const number = (counters.get(line.indent) ?? 0) + 1; + counters.set(line.indent, number); + previousIndent = line.indent; + return formatLine({ ...line, number }); + }) + .join('\n'); +} + +/** + * What pressing Enter should produce inside a list. + * + * Returns null when the caret is not in a list item, letting the TextInput + * insert an ordinary newline. An Enter on an empty item ends the list instead + * of adding another empty one -- otherwise there is no way out but backspace. + */ +export function continueList(text: string, selection: Selection): Edit | null { + if (selection.start !== selection.end) return null; + + const offset = selection.start; + const start = lineStart(text, offset); + const line = parseLine(text.slice(start, lineEnd(text, offset))); + if (line.list === 'none') return null; + + if (line.content.trim().length === 0) { + // Empty item: outdent first if it is nested, and only drop the marker at + // the top level, matching how every list editor people already use behaves. + const cleared = + line.indent > 0 + ? formatLine({ ...line, indent: line.indent - 1, content: '' }) + : formatLine({ ...line, list: 'none', style: 'body', content: '' }); + const end = lineEnd(text, offset); + const next = `${text.slice(0, start)}${cleared}${text.slice(end)}`; + const caret = start + cleared.length; + return { text: renumber(next), selection: { start: caret, end: caret } }; + } + + const marker = markerOf({ + ...line, + number: line.number + 1, + checked: false, + // A new list item is body text even when continuing a styled line. + style: 'body', + }); + const inserted = `\n${marker}`; + const next = `${text.slice(0, offset)}${inserted}${text.slice(offset)}`; + const caret = offset + inserted.length; + return { text: renumber(next), selection: { start: caret, end: caret } }; +} + +// --- inline marks ---------------------------------------------------------- + +/** True when the selection is already wrapped in the given mark. */ +export function hasInlineMark(text: string, selection: Selection, mark: InlineMark): boolean { + const [open, close] = INLINE_MARKERS[mark]; + const start = Math.min(selection.start, selection.end); + const end = Math.max(selection.start, selection.end); + const inner = text.slice(start, end); + + if (inner.startsWith(open) && inner.endsWith(close) && inner.length >= open.length + close.length) { + return true; + } + return text.slice(start - open.length, start) === open && text.slice(end, end + close.length) === close; +} + +/** + * Wraps the selection in a mark, or unwraps it when already marked. + * + * With an empty selection this inserts the marker pair and puts the caret + * between them, so tapping B and typing produces bold text. + */ +export function toggleInlineMark(text: string, selection: Selection, mark: InlineMark): Edit { + const [open, close] = INLINE_MARKERS[mark]; + const start = Math.min(selection.start, selection.end); + const end = Math.max(selection.start, selection.end); + const inner = text.slice(start, end); + + // Markers inside the selection. + if (inner.startsWith(open) && inner.endsWith(close) && inner.length >= open.length + close.length) { + const stripped = inner.slice(open.length, inner.length - close.length); + return { + text: `${text.slice(0, start)}${stripped}${text.slice(end)}`, + selection: { start, end: start + stripped.length }, + }; + } + + // Markers hugging the selection. + if ( + text.slice(start - open.length, start) === open && + text.slice(end, end + close.length) === close + ) { + return { + text: `${text.slice(0, start - open.length)}${inner}${text.slice(end + close.length)}`, + selection: { start: start - open.length, end: end - open.length }, + }; + } + + const wrapped = `${open}${inner}${close}`; + return { + text: `${text.slice(0, start)}${wrapped}${text.slice(end)}`, + selection: + start === end + ? { start: start + open.length, end: start + open.length } + : { start, end: start + wrapped.length }, + }; +} + +// --- reading --------------------------------------------------------------- + +/** Removes bold/italic/underline/strikethrough markers, keeping their text. */ +export function stripInlineMarks(text: string): string { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/<\/?u>/g, '') + .replace(/\*(.+?)\*/g, '$1'); +} + +/** Strips every marker, for previews, search and the title. */ +export function toPlainText(text: string): string { + return stripInlineMarks( + text + .split('\n') + .map((line) => parseLine(line).content) + .join('\n'), + ); +} + +/** Characters in the note, markers excluded. The editor's meta line shows this. */ +export function characterCount(text: string): number { + return toPlainText(text).replace(/\s/g, '').length; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0645913..3da8e5a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,4 @@ export * from './title'; export * from './review'; export * from './search'; export * from './note'; +export * from './formatting'; diff --git a/packages/core/src/note.ts b/packages/core/src/note.ts index c19c3f6..80a5b2a 100644 --- a/packages/core/src/note.ts +++ b/packages/core/src/note.ts @@ -18,6 +18,10 @@ export function createNote(input: NewNoteInput, now: Date): Note { body: input.body, title: deriveTitle(input.body), + 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/title.ts b/packages/core/src/title.ts index de00a83..f098c18 100644 --- a/packages/core/src/title.ts +++ b/packages/core/src/title.ts @@ -1,3 +1,4 @@ +import { stripInlineMarks } from './formatting'; import { stripHashtags } from './hashtags'; export const DEFAULT_TITLE_LENGTH = 80; @@ -14,8 +15,8 @@ const LEADING_MARKUP = /^\s*(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s*)?|>\s+|\d+[.)]\ */ export function deriveTitle(body: string, maxLength: number = DEFAULT_TITLE_LENGTH): string | null { for (const rawLine of body.split('\n')) { - // Strip markup first: a line that is only `## ` is still empty. - const line = collapse(stripHashtags(rawLine.replace(LEADING_MARKUP, ''))); + // Strip markup first: a line that is only `## ` or `**` is still empty. + const line = collapse(stripInlineMarks(stripHashtags(rawLine.replace(LEADING_MARKUP, '')))); if (line.length === 0) continue; return truncate(line, maxLength); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c5e2d58..6e135a4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -22,6 +22,14 @@ export interface Note { /** Derived from the first line and cached. See `deriveTitle`. */ title: 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; @@ -66,4 +74,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; From f514f3d83c607287137a6707b97c10a559472583 Mon Sep 17 00:00:00 2001 From: Kshitij Bachhav Date: Tue, 4 Aug 2026 17:39:01 +0530 Subject: [PATCH 2/3] notes: rebuild the UI around the reference app Replaces the composer-as-launch-screen with the shape the reference uses: the library is the front door and capture is reached through the floating button. Screens: - (tabs)/index -- large title, note count, notebook filter rail, and a grouped card whose rows share one surface with hairline dividers. Date and preview share a line, as in the reference. Amber FAB. - (tabs)/todos -- a view over the notes table, not a second store: a to-do is a note that was promoted to a commitment. Due-ness ticks on a timer so an item moves into Due while you are looking at it. - notebooks -- the drawer: All notes, the user's notebooks with counts and swatches, then the fixed Default / Locked / Recently deleted shelves. One sheet serves both create and rename, because Alert.prompt is iOS-only and would have silently done nothing on Android. - note/[id] -- back/undo/redo/overflow/done, the meta line (edited-at, character count, notebook), a Heading field and body, and the editing rail. The title is the body's first line rather than its own column, which is what keeps deriveTitle honest: renaming a note and editing its first line are the same act. - trash, locked -- soft-delete recovery, and notes behind the device lock. Locking delegates to the OS rather than inventing an in-app PIN that would then have to be stored somewhere. Format sheet applies the core transforms and doubles as a readout of what the caret is already inside. Alignment is whole-note because a TextInput has one textAlign for all of its content. The Add sheet is present but inert, and says so: real attachments need a file store and an editor that can render them inline, and neither exists yet. Better to show the shape and be honest than to fail silently on tap. Co-Authored-By: Claude Opus 5 --- apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/_layout.tsx | 49 ++ apps/mobile/src/app/(tabs)/index.tsx | 406 ++++++++++++ apps/mobile/src/app/(tabs)/todos.tsx | 147 +++++ apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/index.tsx | 198 ------ apps/mobile/src/app/locked.tsx | 151 +++++ apps/mobile/src/app/note/[id].tsx | 646 ++++++++++++++++---- apps/mobile/src/app/notebooks.tsx | 354 +++++++++++ apps/mobile/src/app/notes.tsx | 200 ------ apps/mobile/src/app/trash.tsx | 159 +++++ apps/mobile/src/components/format-sheet.tsx | 165 +++++ apps/mobile/src/components/icon.tsx | 83 +++ apps/mobile/src/components/sheet.tsx | 109 ++++ apps/mobile/src/lib/format.ts | 21 + apps/mobile/src/lib/lock.ts | 36 ++ apps/mobile/src/lib/undo.ts | 99 +++ apps/mobile/src/store/notes-store.tsx | 15 + apps/mobile/src/theme/index.ts | 12 + package-lock.json | 13 + 20 files changed, 2348 insertions(+), 517 deletions(-) create mode 100644 apps/mobile/src/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/src/app/(tabs)/index.tsx create mode 100644 apps/mobile/src/app/(tabs)/todos.tsx delete mode 100644 apps/mobile/src/app/index.tsx create mode 100644 apps/mobile/src/app/locked.tsx create mode 100644 apps/mobile/src/app/notebooks.tsx delete mode 100644 apps/mobile/src/app/notes.tsx create mode 100644 apps/mobile/src/app/trash.tsx create mode 100644 apps/mobile/src/components/format-sheet.tsx create mode 100644 apps/mobile/src/components/icon.tsx create mode 100644 apps/mobile/src/components/sheet.tsx create mode 100644 apps/mobile/src/lib/lock.ts create mode 100644 apps/mobile/src/lib/undo.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 854bfca..ce09708 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -24,6 +24,7 @@ "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "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..f76d2a7 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -0,0 +1,406 @@ +import { + ALL_NOTES, + DEFAULT_NOTEBOOK, + searchNotes, + toPlainText, + 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 and + // stripped of formatting markers -- `## ` in a list row is noise. + const preview = useMemo(() => { + const rest = note.body.split('\n').slice(1).join(' '); + return toPlainText(rest).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 15b9027..17a1536 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -80,6 +80,7 @@ export default function RootLayout() { options={{ presentation: 'modal', animation: 'slide_from_bottom' }} /> + diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx deleted file mode 100644 index 21c3c4c..0000000 --- a/apps/mobile/src/app/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { hasCommitmentTag, parseHashtags } from '@dailynote/core'; -import { Link } from 'expo-router'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - KeyboardAvoidingView, - Platform, - Pressable, - Text, - TextInput, - View, -} from 'react-native'; -import Animated, { - interpolateColor, - useAnimatedStyle, - useSharedValue, - withSequence, - withSpring, -} from 'react-native-reanimated'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -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 inputRef = useRef(null); - const [body, setBody] = useState(''); - const [saving, setSaving] = useState(false); - - const tags = parseHashtags(body); - const willCommit = hasCommitmentTag(body); - const canSave = body.trim().length > 0 && !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 (body.trim().length === 0 || saving) return; - setSaving(true); - try { - const note = await create(body); - // 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(); - setBody(''); - inputRef.current?.focus(); - } finally { - setSaving(false); - } - }, [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} - - - - - ); -} diff --git a/apps/mobile/src/app/locked.tsx b/apps/mobile/src/app/locked.tsx new file mode 100644 index 0000000..ffe7f83 --- /dev/null +++ b/apps/mobile/src/app/locked.tsx @@ -0,0 +1,151 @@ +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 { 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 { 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 ( + + + router.back()} + className="h-11 w-11 items-center justify-center rounded-full active:opacity-50" + > + + + 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 b40ca53..b01fae4 100644 --- a/apps/mobile/src/app/note/[id].tsx +++ b/apps/mobile/src/app/note/[id].tsx @@ -1,7 +1,22 @@ -import { threadOf, type Note } from '@dailynote/core'; -import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { useMemo, useState } from 'react'; import { + applyBlockStyle, + applyListStyle, + changeIndent, + characterCount, + continueList, + toggleInlineMark, + type Align, + type BlockStyle, + type Edit, + type InlineMark, + type ListStyle, + type Note, + type Selection, +} from '@dailynote/core'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, KeyboardAvoidingView, Platform, Pressable, @@ -9,149 +24,542 @@ import { Text, TextInput, View, + type NativeSyntheticEvent, + type TextInputSelectionChangeEventData, } from 'react-native'; -import Animated from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; -import { comesBack, formatDay } from '@/lib/format'; +import { FormatSheet } from '@/components/format-sheet'; +import { Icon, type IconName } from '@/components/icon'; +import { Sheet, SheetButton } from '@/components/sheet'; +import { editedAt } from '@/lib/format'; import { haptics } from '@/lib/haptics'; -import * as motion from '@/lib/motion'; -import { AnimatedPressable } from '@/lib/motion'; +import { useHistory } from '@/lib/undo'; +import { useNotebooks } from '@/store/notebooks-store'; import { useNotes } from '@/store/notes-store'; import { useTheme } from '@/theme'; -/** Note detail: edit the body, promote to a commitment, read the thread. */ -export default function NoteDetail() { +/** How long after the last keystroke the note is written to SQLite. */ +const AUTOSAVE_MS = 800; + +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 the stored body 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 draft initialises from the stored body exactly once. Store + // refreshes must never clobber what is being typed. + return ; +} + +function Missing() { + const router = useRouter(); + return ( + + This note is gone. + router.back()} className="mt-4 active:opacity-60"> + Go back + + + ); } -function NoteEditor({ note }: { note: Note }) { +/** + * The note editor. + * + * The title is the body's first line rather than a separate column, which is + * what keeps `deriveTitle` honest -- renaming the note and editing its first + * line are the same act. The two TextInputs are a presentation split over one + * string, joined on every keystroke. + */ +function Editor({ note }: { note: Note | null }) { const theme = useTheme(); const router = useRouter(); - const { notes, setBody, softDelete, promote } = useNotes(); - const [draft, setDraft] = useState(note.body); + const { create, setBody, softDelete, promote, move, togglePin, setLocked } = useNotes(); + const { notebooks, find } = useNotebooks(); - const updates = useMemo( - () => threadOf(notes, note.rootId).filter((candidate) => candidate.id !== note.id), - [notes, note.rootId, note.id], + const stored = note?.body ?? ''; + const [title, setTitle] = useState(() => stored.split('\n')[0] ?? ''); + const history = useHistory(stored.split('\n').slice(1).join('\n')); + const [align, setAlign] = useState('left'); + + const [selection, setSelection] = useState({ start: 0, end: 0 }); + const [pending, setPending] = useState(null); + const [sheet, setSheet] = useState<'format' | 'add' | 'more' | 'notebook' | null>(null); + + const bodyRef = useRef(null); + // The id is only known after the first save for a brand new note. + const noteId = useRef(note?.id ?? null); + const savedRef = useRef(stored); + const [editedIso, setEditedIso] = useState(note?.updatedAt ?? new Date().toISOString()); + + const body = history.value; + const composed = useMemo( + () => (body.length > 0 ? `${title}\n${body}` : title), + [title, body], + ); + + const notebook = find(note?.notebookId ?? null); + const count = characterCount(composed); + + // --- persistence --------------------------------------------------------- + + const persist = useCallback( + async (text: string) => { + if (text === savedRef.current) return; + if (text.trim().length === 0 && noteId.current === null) return; + + savedRef.current = text; + if (noteId.current === null) { + // A new note is not written until it has content, so backing out of an + // empty editor leaves nothing behind. + const created = await create(text); + noteId.current = created.id; + } else { + await setBody(noteId.current, text); + } + setEditedIso(new Date().toISOString()); + }, + [create, setBody], ); - const isCommitment = note.nextReviewAt !== null; + // 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(composed), AUTOSAVE_MS); + return () => clearTimeout(timer); + }, [composed, persist]); + + // Tracked in a ref so the unmount flush below can read the latest text + // without re-subscribing -- an unmount cleanup that depended on `composed` + // would fire on every keystroke instead of on the way out. + const composedRef = useRef(composed); + useEffect(() => { + composedRef.current = composed; + }, [composed]); + + useEffect(() => { + // Flush on unmount, so backing out never loses the last few characters. + return () => { + void persist(composedRef.current); + }; + }, [persist]); + + // --- editing ------------------------------------------------------------- + + /** Applies a transform, recording it as its own undo step. */ + const apply = useCallback( + (edit: Edit | null) => { + if (edit === null) return; + history.set(edit.text, edit.selection, { discrete: true }); + setSelection(edit.selection); + setPending(edit.selection); + }, + [history], + ); + + const onChangeBody = useCallback( + (next: string) => { + const previous = history.value; + + // Enter inside a list continues it. Detected by diffing rather than + // onKeyPress, which does not fire reliably for Enter on Android. + if (next.length === previous.length + 1) { + const at = selection.start; + const isNewlineHere = + next[at] === '\n' && + next.slice(0, at) === previous.slice(0, at) && + next.slice(at + 1) === previous.slice(at); + + if (isNewlineHere) { + const continued = continueList(previous, { start: at, end: at }); + if (continued !== null) { + apply(continued); + return; + } + } + } + + history.set(next, selection); + }, + [history, selection, apply], + ); + + const onSelectionChange = useCallback( + (event: NativeSyntheticEvent) => { + setSelection(event.nativeEvent.selection); + // Release the controlled selection once the input has honoured it, so + // it does not fight the caret on the next keystroke. + setPending(null); + }, + [], + ); + + const undo = useCallback(() => { + const entry = history.undo(); + if (entry !== null) { + setSelection(entry.selection); + setPending(entry.selection); + haptics.tap(); + } + }, [history]); + + const redo = useCallback(() => { + const entry = history.redo(); + if (entry !== null) { + setSelection(entry.selection); + setPending(entry.selection); + haptics.tap(); + } + }, [history]); + + const onBlockStyle = useCallback( + (style: BlockStyle) => apply(applyBlockStyle(history.value, selection, style)), + [apply, history.value, selection], + ); + const onList = useCallback( + (list: ListStyle) => apply(applyListStyle(history.value, selection, list)), + [apply, history.value, selection], + ); + const onMark = useCallback( + (mark: InlineMark) => apply(toggleInlineMark(history.value, selection, mark)), + [apply, history.value, selection], + ); + const onIndent = useCallback( + (delta: number) => apply(changeIndent(history.value, selection, delta)), + [apply, history.value, selection], + ); + + /** The checklist button on the main toolbar, as in the reference. */ + const toggleChecklist = useCallback(() => { + haptics.tap(); + onList('checklist'); + bodyRef.current?.focus(); + }, [onList]); + + // --- actions ------------------------------------------------------------- + + const done = useCallback(async () => { + haptics.tap(); + await persist(composed); + router.back(); + }, [persist, composed, router]); + + 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. + savedRef.current = composed; + if (noteId.current !== null) await softDelete(noteId.current); + router.back(); + })(); + }, + }, + ]); + }, [softDelete, router, composed]); + + const requireSaved = useCallback(async () => { + await persist(composed); + return noteId.current; + }, [persist, composed]); 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'}`} + - { - if (draft !== note.body) void setBody(note.id, draft); - }} - multiline - textAlignVertical="top" - className="min-h-[220px] px-5 pt-3 font-serif text-[21px] leading-8 text-ink web:outline-none dark:text-ink-dark" - selectionColor={theme.accent} - /> - - {/* 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!)} - - - ) : ( - + + bodyRef.current?.focus()} + className="px-5 pt-4 text-[30px] font-bold leading-9 text-ink web:outline-none dark:text-ink-dark" + /> + + + + + {/* The reference's editing rail, pinned above the keyboard. */} + + setSheet('format')} + tint={theme.accent} + /> + + onList('bullet')} /> + onList('numbered')} /> + setSheet('add')} /> + + + + setSheet(null)} + text={body} + selection={selection} + onEdit={apply} + align={align} + onAlign={setAlign} + onBlockStyle={onBlockStyle} + onList={onList} + onMark={onMark} + onIndent={onIndent} + /> + + setSheet(null)} /> + + 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); + router.back(); + } + })(); + }} + /> + + + + + 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 + /> + ))} + + + + ); +} + +/** + * The attachment panel. + * + * Photos, Camera and Files are inert for now: the body is a plain-text string, + * and putting real attachments in it needs a store for the files plus an + * editor that can render them inline -- neither of which exists yet. The panel + * is here because it is part of the screen's shape, and it says so plainly + * rather than failing silently when tapped. + */ +function AddSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) { + const items: readonly { icon: IconName; label: string }[] = [ + { icon: 'photos', label: 'Photos' }, + { icon: 'camera', label: 'Camera' }, + { icon: 'doodle', label: 'Doodle' }, + { icon: 'spreadsheet', label: 'Spreadsheet' }, + { icon: 'files', label: 'Files' }, + ]; + + return ( + + + {items.map((item) => ( + + {}} flex={false} /> + + {item.label} - {updates.map((update, index) => ( - - - {formatDay(update.createdAt)} - - - {update.body} - - - ))} + ))} + + + Attachments are not available yet. + + + ); +} + +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} - - + + {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..0933565 --- /dev/null +++ b/apps/mobile/src/app/notebooks.tsx @@ -0,0 +1,354 @@ +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 { 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 { 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 ( + + + router.back()} + className="h-11 w-11 items-center justify-center rounded-full active:opacity-50" + > + + + 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..a83dd61 --- /dev/null +++ b/apps/mobile/src/app/trash.tsx @@ -0,0 +1,159 @@ +import type { Note } from '@dailynote/core'; +import { useRouter } from 'expo-router'; +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 { 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 router = useRouter(); + 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 ( + + + router.back()} + className="h-11 w-11 items-center justify-center rounded-full active:opacity-50" + > + + + + 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/format-sheet.tsx b/apps/mobile/src/components/format-sheet.tsx new file mode 100644 index 0000000..f99c003 --- /dev/null +++ b/apps/mobile/src/components/format-sheet.tsx @@ -0,0 +1,165 @@ +import { + blockStyleAt, + hasInlineMark, + listStyleAt, + type Align, + type BlockStyle, + type Edit, + type InlineMark, + type ListStyle, + type Selection, +} from '@dailynote/core'; +import { Pressable, ScrollView, Text, View } from 'react-native'; + +import { Icon } from '@/components/icon'; +import { Sheet, SheetButton } from '@/components/sheet'; +import { useTheme } from '@/theme'; + +/** The style chips, in the reference's order. */ +const STYLES: readonly { key: BlockStyle; label: string; size: number; weight: '400' | '600' | '700' }[] = [ + { key: 'title', label: 'Title', size: 26, weight: '700' }, + { key: 'subtitle', label: 'Subtitle', size: 21, weight: '700' }, + { key: 'heading', label: 'Heading', size: 17, weight: '600' }, + { key: 'body', label: 'Body', size: 16, weight: '400' }, + { key: 'note', label: 'Note', size: 14, weight: '400' }, +]; + +const MARKS: readonly { key: InlineMark; icon: 'bold' | 'italic' | 'underline' | 'strikethrough' }[] = [ + { key: 'bold', icon: 'bold' }, + { key: 'italic', icon: 'italic' }, + { key: 'underline', icon: 'underline' }, + { key: 'strikethrough', icon: 'strikethrough' }, +]; + +const LISTS: readonly { key: ListStyle; icon: 'bulletList' | 'numberedList' | 'checklist' }[] = [ + { key: 'bullet', icon: 'bulletList' }, + { key: 'numbered', icon: 'numberedList' }, + { key: 'checklist', icon: 'checklist' }, +]; + +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 body being edited, and the current selection within it. */ + text: string; + selection: Selection; + /** Applies a transform's result back to the editor. */ + onEdit: (edit: Edit) => void; + align: Align; + onAlign: (align: Align) => void; + onBlockStyle: (style: BlockStyle) => void; + onList: (list: ListStyle) => void; + onMark: (mark: InlineMark) => void; + onIndent: (delta: number) => void; +} + +/** + * The formatting panel. + * + * Every control reports what is currently applied at the caret, so the sheet + * doubles as a readout: opening it tells you what the line you are on already + * is. Alignment is whole-note rather than per-line because a TextInput has one + * `textAlign` for all of its content. + */ +export function FormatSheet({ + visible, + onClose, + text, + selection, + align, + onAlign, + onBlockStyle, + onList, + onMark, + onIndent, +}: FormatSheetProps) { + const theme = useTheme(); + const currentStyle = blockStyleAt(text, selection); + const currentList = listStyleAt(text, selection); + + return ( + + + {STYLES.map((style) => { + const active = currentStyle === style.key; + return ( + onBlockStyle(style.key)} + className={[ + 'justify-center rounded-xl px-4 py-2 active:opacity-60', + active ? 'bg-accent dark:bg-accent-dark' : '', + ].join(' ')} + > + + {style.label} + + + ); + })} + + + + {MARKS.map((mark) => ( + onMark(mark.key)} + /> + ))} + + + + {LISTS.map((list) => ( + onList(list.key)} + /> + ))} + onIndent(1)} /> + onIndent(-1)} /> + + + + {ALIGNMENTS.map((option) => ( + onAlign(option.key)} + /> + ))} + + + + Highlight not yet available + + + + + ); +} 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/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/undo.ts b/apps/mobile/src/lib/undo.ts new file mode 100644 index 0000000..e8248f1 --- /dev/null +++ b/apps/mobile/src/lib/undo.ts @@ -0,0 +1,99 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { Selection } from '@dailynote/core'; + +/** One point in the edit history: the text and where the caret was in it. */ +export interface Snapshot { + text: string; + selection: Selection; +} + +export interface History { + value: string; + canUndo: boolean; + canRedo: boolean; + /** Records a new value. Consecutive typing coalesces into one entry. */ + set: (text: string, selection: Selection, options?: { discrete?: boolean }) => void; + undo: () => Snapshot | null; + redo: () => Snapshot | null; + /** Replaces the value without recording history, e.g. when loading a note. */ + reset: (text: string) => void; +} + +/** How long a run of typing keeps folding into a single undo entry. */ +const COALESCE_MS = 700; + +/** + * Undo/redo over a single text value. + * + * Typing coalesces so that one Undo does not walk back a character at a time, + * but anything the toolbar does is recorded discretely -- applying a heading + * and then undoing it should put the heading back, not eat the word before it. + */ +export function useHistory(initial: string): History { + const [value, setValue] = useState(initial); + + const past = useRef([]); + const future = useRef([]); + const current = useRef({ text: initial, selection: { start: 0, end: 0 } }); + 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((text, selection, options) => { + const previous = current.current; + if (previous.text === text) { + current.current = { text, selection }; + 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 = { text, selection }; + setValue(text); + 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.text); + 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.text); + setDepth({ past: past.current.length, future: future.current.length }); + return entry; + }, []); + + const reset = useCallback((text: string) => { + past.current = []; + future.current = []; + current.current = { text, selection: { start: 0, end: 0 } }; + lastEntryAt.current = 0; + setValue(text); + 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/notes-store.tsx b/apps/mobile/src/store/notes-store.tsx index 972f272..d588dfc 100644 --- a/apps/mobile/src/store/notes-store.tsx +++ b/apps/mobile/src/store/notes-store.tsx @@ -36,6 +36,8 @@ interface NotesContextValue { 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; } @@ -125,6 +127,19 @@ export function NotesProvider({ children }: { children: ReactNode }) { 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/index.ts b/apps/mobile/src/theme/index.ts index ed4e77f..8697495 100644 --- a/apps/mobile/src/theme/index.ts +++ b/apps/mobile/src/theme/index.ts @@ -32,3 +32,15 @@ export function useTheme(): Theme { const dark = useColorScheme() === 'dark'; 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/package-lock.json b/package-lock.json index 1f62861..c9773c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "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", @@ -7843,6 +7844,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", From 5774a2b2bc95ff10edbec90945bfd66138e3dd74 Mon Sep 17 00:00:00 2001 From: Kshitij Bachhav Date: Tue, 4 Aug 2026 17:47:34 +0530 Subject: [PATCH 3/3] notes: label the list's overflow button for what it opens It opened the notebooks drawer while announcing itself as "More", which also made it indistinguishable from the editor's own overflow button to anything reading by label. Co-Authored-By: Claude Opus 5 --- apps/mobile/src/app/(tabs)/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index f76d2a7..e4ed042 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -91,7 +91,7 @@ export default function NotesList() { onPress={openSearch} tint={searching ? theme.accent : theme.ink} /> - router.push('/notebooks')} /> + router.push('/notebooks')} />