diff --git a/.github/workflows/file-length.yml b/.github/workflows/file-length.yml new file mode 100644 index 0000000..5bba76a --- /dev/null +++ b/.github/workflows/file-length.yml @@ -0,0 +1,33 @@ +name: file-length + +# The 250-line cap, enforced on push as well as at commit time. The local +# pre-commit hook can be skipped (a fresh clone that never ran +# scripts/install_hooks.sh has no hooks at all), so the cap needs a second +# gate that no one can forget to install. +# +# Deliberately a SEPARATE workflow from release-apk.yml: a file that grew too +# long should fail lint, not block publishing an APK. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + file-length: + name: file length <= 250 lines + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The gate and its exemption list live in the shared utils repo so every + # repo enforces the same cap. Checking it out beats vendoring a copy + # here, which is exactly how a gate and a survey start disagreeing. + - name: Check out the shared gate + uses: actions/checkout@v4 + with: + repository: kuhyx/utils + path: .utils + + - name: Enforce the cap + run: bash .utils/scripts/check_file_length.sh --all diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6b061be --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +# Pre-commit hooks for todo. +# +# This repo previously used a hand-written .git/hooks/pre-commit installed by +# scripts/install_hooks.sh. It moved to the pre-commit framework when the +# 250-line file-length gate was added, so all three checks live in one place +# with one install path. The two pre-existing checks are ported verbatim +# below -- dropping them to add a new gate would trade one silent failure for +# another. +# +# Install with: pre-commit install +# The hooks are `language: system` because they are plain bash/python already +# on this machine; pre-commit must not build an isolated env for them. +repos: + - repo: local + hooks: + # Gate first: a local crdt_sync override left in place makes CI build a + # library this checkout has never tested against, silently and in both + # directions. Blocking the commit is the only thing that prevents it. + - id: no-crdt-sync-override + name: no local crdt_sync dependency_override + entry: scripts/check_no_crdt_sync_override.sh + language: system + files: ^pubspec\.yaml$ + + # The shared 250-line cap. Checks only the files in the commit, so an + # unrelated commit never breaks; exits 1 rather than warning. No + # baseline and no allowlist -- those are suppressions. The exemption + # list (generated/vendored/data files) lives with the checker in + # ~/utils/file_length/config.py so the gate and the survey cannot + # disagree. + - id: file-length + name: file length <= 250 lines + entry: scripts/check_file_length.sh + language: system + + # Version bump LAST: it rewrites pubspec.yaml and `git add`s it, so it + # must not run before a gate that could still reject the commit. + - id: bump-patch-version + name: bump patch version + entry: scripts/bump_patch_version.sh + language: system + pass_filenames: false + always_run: true diff --git a/lib/data/note_template.dart b/lib/data/note_template.dart index f9d7f7e..e72680e 100644 --- a/lib/data/note_template.dart +++ b/lib/data/note_template.dart @@ -1,4 +1,4 @@ -/// Structured note templates and the pure text <-> sections conversion. +/// Structured note templates: the section types and the template data. /// /// The whole point of a template is that the user fills it, then copies the /// note verbatim into an LLM, and the LLM has everything it needs to start @@ -12,18 +12,18 @@ /// verbatim to an LLM, so the description of what each section means must /// travel with it. /// -/// Design invariants (see the round-trip tests): -/// * The **template** defines which sections exist; the stored text only -/// carries values. Parsing an unknown/legacy note never invents sections. -/// * `assemble(parse(text))` is idempotent for any text that *conforms* to a -/// template, so opening and closing a structured note never mutates it. -/// * Text that does **not** conform (freeform notes, the old `what —` format, -/// the pre-2026-07 twelve-section spec, hand-mangled raw edits) is reported -/// as such so the UI can fall back to a raw editor and show it verbatim — -/// we never force non-conforming text into the guided stepper and never -/// drop a line we couldn't place. +/// The conversion itself ([assemble], [parse], [noteTitle], [isBareLink]) lives +/// in `note_template_parser.dart` and is re-exported below, so importing this +/// file still gets the whole template API in one import. library; +// Imported as well as exported: `export` re-publishes the parser to this +// library's importers but does not bring its names into scope here, and +// NoteTemplate.forDraft calls isBareLink. +import 'package:todo/data/note_template_parser.dart'; + +export 'note_template_parser.dart'; + /// One field of a structured template. class TemplateSection { /// Creates a [TemplateSection] from its field/label/helper text. @@ -201,170 +201,3 @@ class NoteTemplate { static NoteTemplate forDraft(String text) => isBareLink(text) ? blank : defaultTemplate; } - -/// Matches a single `http(s)://…` token. -final RegExp _linkPattern = RegExp(r'^https?://\S+$'); - -/// Whether [text] is nothing but one or more links. -/// -/// Deliberately strict: one word of context ("read this https://…") means the -/// note has prose in it and is no longer a bare link. -bool isBareLink(String text) { - final tokens = text.trim().split(RegExp(r'\s+')) - ..removeWhere((t) => t.isEmpty); - if (tokens.isEmpty) return false; - return tokens.every(_linkPattern.hasMatch); -} - -/// Result of parsing stored text against a template. -class ParsedNote { - /// Creates a [ParsedNote] from its parsed [values] and [conforms] flag. - const ParsedNote({required this.values, required this.conforms}); - - /// Section-key -> value (trimmed). Sections absent from the text map to ''. - final Map values; - - /// Whether [text] cleanly matched [template]. When false the UI must show - /// the text in a raw editor rather than the guided stepper. - final bool conforms; -} - -/// Builds the stored note text from section [values], dropping empty sections -/// so the pasted note carries no blank scaffold for the LLM to wade through. -/// -/// [template] fixes the section order; [values] is keyed by section key. Each -/// present section becomes a `## label` heading, an italic guidance line, and -/// the value. -String assemble(NoteTemplate template, Map values) { - if (template.isFreeform) return (values['body'] ?? '').trimRight(); - - final blocks = []; - final title = (values['title'] ?? '').trim(); - if (title.isNotEmpty) blocks.add('# $title'); - - for (final section in template.sections) { - if (section.isTitle) continue; - final value = (values[section.key] ?? '').trim(); - if (value.isEmpty) continue; // drop empty sections - blocks.add('## ${section.label}\n_${section.helper}_\n\n$value'); - } - return blocks.join('\n\n'); -} - -/// Parses stored [text] into section values for the given [template]. -/// -/// Freeform templates always conform: the whole text is the `body` value. -/// For structured templates the text conforms only if it uses the Markdown -/// section headings, those headings are known and in template order without -/// repeats, and nothing but the `#` title sits before the first section. -/// Anything else (freeform, the old `what —` format) is reported as -/// non-conforming so the caller can show it raw, untouched. -ParsedNote parse(NoteTemplate template, String text) { - if (template.isFreeform) { - return ParsedNote(values: {'body': text}, conforms: true); - } - - // Index of each non-title section in template order, used to detect - // out-of-order or duplicate headings. - final order = {}; - var oi = 0; - for (final s in template.sections) { - if (!s.isTitle) order[s.key] = oi++; - } - - final values = {for (final s in template.sections) s.key: ''}; - final lines = text.split('\n'); - - final titleLines = []; - String? currentKey; // open section accumulating its block lines - final blocks = >{}; - var lastOrder = -1; - var sawSection = false; - var conforms = true; - - for (final line in lines) { - final heading = _matchHeading(line, template); - if (heading != null) { - sawSection = true; - final idx = order[heading.key]!; - if (idx <= lastOrder) conforms = false; // out of order / duplicate - lastOrder = idx; - currentKey = heading.key; - blocks[heading.key] = []; - continue; - } - // A heading the template retired means the note predates the current - // section set. Keep the line as content (we never drop one) but refuse to - // call the note conforming, so the UI shows it raw instead of letting the - // stepper fold the retired section into its neighbour on the next save. - if (_isRetiredHeading(line)) conforms = false; - if (currentKey != null) { - blocks[currentKey]!.add(line); - continue; - } - // Still before the first section: only the `#` title and blanks belong. - final trimmed = line.trim(); - if (trimmed.isEmpty) continue; - if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) { - titleLines.add(trimmed.substring(2).trim()); - } else { - conforms = false; // stray content before the first section - } - } - - values['title'] = titleLines.join('\n').trim(); - for (final entry in blocks.entries) { - values[entry.key] = _stripGuidance(entry.value).trim(); - } - - // A structured note with no recognised section headings is really freeform. - if (!sawSection) conforms = false; - - return ParsedNote(values: values, conforms: conforms); -} - -/// The note's display title: the `#` heading text without its `#` marker, or -/// the first non-empty line for freeform notes. -String noteTitle(String text) { - for (final line in text.split('\n')) { - final trimmed = line.trim(); - if (trimmed.isEmpty) continue; - return trimmed.replaceFirst(RegExp(r'^#+\s*'), ''); - } - return ''; -} - -/// Returns the section whose `## label` heading is on [line], or null. Only -/// known section labels are treated as headings, so a `## subheading` the user -/// wrote inside a value is preserved as content rather than splitting the note. -TemplateSection? _matchHeading(String line, NoteTemplate template) { - if (!line.startsWith('## ')) return null; - final label = line.substring(3).trim(); - for (final s in template.sections) { - if (!s.isTitle && s.label == label) return s; - } - return null; -} - -/// Whether [line] is a `## heading` naming a section the template retired. -bool _isRetiredHeading(String line) { - if (!line.startsWith('## ')) return false; - return NoteTemplate.retiredLabels.contains(line.substring(3).trim()); -} - -/// Drops the leading blank lines and the single italic guidance line (if any) -/// from a section's block, leaving just the user's value. -String _stripGuidance(List lines) { - final out = List.from(lines); - var i = 0; - while (i < out.length && out[i].trim().isEmpty) { - i++; - } - if (i < out.length) { - final t = out[i].trim(); - if (t.length >= 2 && t.startsWith('_') && t.endsWith('_')) { - out.removeAt(i); - } - } - return out.join('\n'); -} diff --git a/lib/data/note_template_parser.dart b/lib/data/note_template_parser.dart new file mode 100644 index 0000000..8848779 --- /dev/null +++ b/lib/data/note_template_parser.dart @@ -0,0 +1,186 @@ +/// The pure text <-> sections conversion for [NoteTemplate]. +/// +/// Split out of `note_template.dart` (which now owns only the template types +/// and the template data) purely for file size; `note_template.dart` re-exports +/// everything here, so callers import that one file as before. +/// +/// Design invariants (see the round-trip tests): +/// * The **template** defines which sections exist; the stored text only +/// carries values. Parsing an unknown/legacy note never invents sections. +/// * `assemble(parse(text))` is idempotent for any text that *conforms* to a +/// template, so opening and closing a structured note never mutates it. +/// * Text that does **not** conform (freeform notes, the old `what —` format, +/// the pre-2026-07 twelve-section spec, hand-mangled raw edits) is reported +/// as such so the UI can fall back to a raw editor and show it verbatim — +/// we never force non-conforming text into the guided stepper and never +/// drop a line we couldn't place. +library; + +import 'package:todo/data/note_template.dart'; + +/// Matches a single `http(s)://…` token. +final RegExp _linkPattern = RegExp(r'^https?://\S+$'); + +/// Whether [text] is nothing but one or more links. +/// +/// Deliberately strict: one word of context ("read this https://…") means the +/// note has prose in it and is no longer a bare link. +bool isBareLink(String text) { + final tokens = text.trim().split(RegExp(r'\s+')) + ..removeWhere((t) => t.isEmpty); + if (tokens.isEmpty) return false; + return tokens.every(_linkPattern.hasMatch); +} + +/// Result of parsing stored text against a template. +class ParsedNote { + /// Creates a [ParsedNote] from its parsed [values] and [conforms] flag. + const ParsedNote({required this.values, required this.conforms}); + + /// Section-key -> value (trimmed). Sections absent from the text map to ''. + final Map values; + + /// Whether the text cleanly matched the template. When false the UI must + /// show the text in a raw editor rather than the guided stepper. + final bool conforms; +} + +/// Builds the stored note text from section [values], dropping empty sections +/// so the pasted note carries no blank scaffold for the LLM to wade through. +/// +/// [template] fixes the section order; [values] is keyed by section key. Each +/// present section becomes a `## label` heading, an italic guidance line, and +/// the value. +String assemble(NoteTemplate template, Map values) { + if (template.isFreeform) return (values['body'] ?? '').trimRight(); + + final blocks = []; + final title = (values['title'] ?? '').trim(); + if (title.isNotEmpty) blocks.add('# $title'); + + for (final section in template.sections) { + if (section.isTitle) continue; + final value = (values[section.key] ?? '').trim(); + if (value.isEmpty) continue; // drop empty sections + blocks.add('## ${section.label}\n_${section.helper}_\n\n$value'); + } + return blocks.join('\n\n'); +} + +/// Parses stored [text] into section values for the given [template]. +/// +/// Freeform templates always conform: the whole text is the `body` value. +/// For structured templates the text conforms only if it uses the Markdown +/// section headings, those headings are known and in template order without +/// repeats, and nothing but the `#` title sits before the first section. +/// Anything else (freeform, the old `what —` format) is reported as +/// non-conforming so the caller can show it raw, untouched. +ParsedNote parse(NoteTemplate template, String text) { + if (template.isFreeform) { + return ParsedNote(values: {'body': text}, conforms: true); + } + + // Index of each non-title section in template order, used to detect + // out-of-order or duplicate headings. + final order = {}; + var oi = 0; + for (final s in template.sections) { + if (!s.isTitle) order[s.key] = oi++; + } + + final values = {for (final s in template.sections) s.key: ''}; + final lines = text.split('\n'); + + final titleLines = []; + String? currentKey; // open section accumulating its block lines + final blocks = >{}; + var lastOrder = -1; + var sawSection = false; + var conforms = true; + + for (final line in lines) { + final heading = _matchHeading(line, template); + if (heading != null) { + sawSection = true; + final idx = order[heading.key]!; + if (idx <= lastOrder) conforms = false; // out of order / duplicate + lastOrder = idx; + currentKey = heading.key; + blocks[heading.key] = []; + continue; + } + // A heading the template retired means the note predates the current + // section set. Keep the line as content (we never drop one) but refuse to + // call the note conforming, so the UI shows it raw instead of letting the + // stepper fold the retired section into its neighbour on the next save. + if (_isRetiredHeading(line)) conforms = false; + if (currentKey != null) { + blocks[currentKey]!.add(line); + continue; + } + // Still before the first section: only the `#` title and blanks belong. + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) { + titleLines.add(trimmed.substring(2).trim()); + } else { + conforms = false; // stray content before the first section + } + } + + values['title'] = titleLines.join('\n').trim(); + for (final entry in blocks.entries) { + values[entry.key] = _stripGuidance(entry.value).trim(); + } + + // A structured note with no recognised section headings is really freeform. + if (!sawSection) conforms = false; + + return ParsedNote(values: values, conforms: conforms); +} + +/// The note's display title: the `#` heading text without its `#` marker, or +/// the first non-empty line for freeform notes. +String noteTitle(String text) { + for (final line in text.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + return trimmed.replaceFirst(RegExp(r'^#+\s*'), ''); + } + return ''; +} + +/// Returns the section whose `## label` heading is on [line], or null. Only +/// known section labels are treated as headings, so a `## subheading` the user +/// wrote inside a value is preserved as content rather than splitting the note. +TemplateSection? _matchHeading(String line, NoteTemplate template) { + if (!line.startsWith('## ')) return null; + final label = line.substring(3).trim(); + for (final s in template.sections) { + if (!s.isTitle && s.label == label) return s; + } + return null; +} + +/// Whether [line] is a `## heading` naming a section the template retired. +bool _isRetiredHeading(String line) { + if (!line.startsWith('## ')) return false; + return NoteTemplate.retiredLabels.contains(line.substring(3).trim()); +} + +/// Drops the leading blank lines and the single italic guidance line (if any) +/// from a section's block, leaving just the user's value. +String _stripGuidance(List lines) { + final out = List.from(lines); + var i = 0; + while (i < out.length && out[i].trim().isEmpty) { + i++; + } + if (i < out.length) { + final t = out[i].trim(); + if (t.length >= 2 && t.startsWith('_') && t.endsWith('_')) { + out.removeAt(i); + } + } + return out.join('\n'); +} diff --git a/lib/desktop/linux_system_probe.dart b/lib/desktop/linux_system_probe.dart new file mode 100644 index 0000000..64e1726 --- /dev/null +++ b/lib/desktop/linux_system_probe.dart @@ -0,0 +1,100 @@ +/// The real-host [SystemProbe]: `ss` for the listener, `/proc` for the rest. +/// +/// Split out of `port_guard.dart` for file size, and it is the natural seam: +/// everything here touches the live host, while `port_guard.dart` keeps the +/// pure decision logic that tests drive with a fake probe. +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:todo/desktop/port_guard.dart'; + +/// NUL separates the arguments inside `/proc//cmdline`. +final String _argumentSeparator = String.fromCharCode(0); + +/// [SystemProbe] backed by `ss` and `/proc`. +/// +/// The desktop wrapper only ever runs on Linux — the desktop app is the web +/// build served locally — so reading `/proc` directly is safe here. +class LinuxSystemProbe implements SystemProbe { + /// Creates the default host probe. + /// + /// [ssExecutable] exists so a test can point at a missing binary and exercise + /// the "no `ss` on PATH" path. + const LinuxSystemProbe({this.ssExecutable = 'ss'}); + + /// Name of the socket-listing tool used to find the port owner. + final String ssExecutable; + + @override + Future listenerPid(int port) async { + try { + final result = await Process.run(ssExecutable, ['-tlnp']); + final out = result.stdout; + return out is String ? parseListenerPid(out, port) : null; + } on ProcessException { + // No `ss` on PATH: report "unknown" and let the caller fail closed. + return null; + } + } + + @override + Future describe(int pid) async { + final cmdline = readCmdline(pid); + if (cmdline == null) return null; + String exe; + try { + exe = Link('/proc/$pid/exe').targetSync(); + } on FileSystemException { + // Unreadable for another user's process; the cmdline still identifies it. + exe = ''; + } + return ProcessDescription(exeTarget: exe, cmdline: cmdline); + } + + @override + Future>> processCmdlines() async { + final cmdlines = >[]; + for (final entry in Directory('/proc').listSync()) { + final pid = int.tryParse(p.basename(entry.path)); + if (pid == null) continue; + final cmdline = readCmdline(pid); + if (cmdline != null) cmdlines.add(cmdline); + } + return cmdlines; + } + + @override + Future portInUse(int port) async { + try { + final socket = await ServerSocket.bind( + InternetAddress.loopbackIPv4, + port, + ); + await socket.close(); + return false; + } on SocketException { + return true; + } + } + + @override + bool signal(int pid, ProcessSignal signal) => Process.killPid(pid, signal); + + /// Reads `/proc//cmdline` as an argument vector. + /// + /// Returns null when the process is gone. Kernel threads yield an empty list, + /// and the trailing separator is dropped so exact-token matching holds. + static List? readCmdline(int pid) { + try { + return File('/proc/$pid/cmdline') + .readAsStringSync() + .split(_argumentSeparator) + .where((token) => token.isNotEmpty) + .toList(); + } on FileSystemException { + return null; + } + } +} diff --git a/lib/desktop/port_guard.dart b/lib/desktop/port_guard.dart index 05ec8c0..16e2a4c 100644 --- a/lib/desktop/port_guard.dart +++ b/lib/desktop/port_guard.dart @@ -4,8 +4,11 @@ import 'dart:io'; import 'package:path/path.dart' as p; -/// NUL separates the arguments inside `/proc//cmdline`. -final String _argumentSeparator = String.fromCharCode(0); +// The real-host probe lives in its own file (size), but PortGuard defaults to +// it and callers expect one import, so it is re-exported here. +import 'package:todo/desktop/linux_system_probe.dart'; + +export 'package:todo/desktop/linux_system_probe.dart'; /// What the launcher should do when the fixed wrapper port is already bound. /// @@ -227,89 +230,3 @@ class PortGuard { return !await probe.portInUse(port); } } - -/// [SystemProbe] backed by `ss` and `/proc`. -/// -/// The desktop wrapper only ever runs on Linux — the desktop app is the web -/// build served locally — so reading `/proc` directly is safe here. -class LinuxSystemProbe implements SystemProbe { - /// Creates the default host probe. - /// - /// [ssExecutable] exists so a test can point at a missing binary and exercise - /// the "no `ss` on PATH" path. - const LinuxSystemProbe({this.ssExecutable = 'ss'}); - - /// Name of the socket-listing tool used to find the port owner. - final String ssExecutable; - - @override - Future listenerPid(int port) async { - try { - final result = await Process.run(ssExecutable, ['-tlnp']); - final out = result.stdout; - return out is String ? parseListenerPid(out, port) : null; - } on ProcessException { - // No `ss` on PATH: report "unknown" and let the caller fail closed. - return null; - } - } - - @override - Future describe(int pid) async { - final cmdline = readCmdline(pid); - if (cmdline == null) return null; - String exe; - try { - exe = Link('/proc/$pid/exe').targetSync(); - } on FileSystemException { - // Unreadable for another user's process; the cmdline still identifies it. - exe = ''; - } - return ProcessDescription(exeTarget: exe, cmdline: cmdline); - } - - @override - Future>> processCmdlines() async { - final cmdlines = >[]; - for (final entry in Directory('/proc').listSync()) { - final pid = int.tryParse(p.basename(entry.path)); - if (pid == null) continue; - final cmdline = readCmdline(pid); - if (cmdline != null) cmdlines.add(cmdline); - } - return cmdlines; - } - - @override - Future portInUse(int port) async { - try { - final socket = await ServerSocket.bind( - InternetAddress.loopbackIPv4, - port, - ); - await socket.close(); - return false; - } on SocketException { - return true; - } - } - - @override - bool signal(int pid, ProcessSignal signal) => Process.killPid(pid, signal); - - /// Reads `/proc//cmdline` as an argument vector. - /// - /// Returns null when the process is gone. Kernel threads yield an empty list, - /// and the trailing separator is dropped so exact-token matching holds. - static List? readCmdline(int pid) { - try { - return File('/proc/$pid/cmdline') - .readAsStringSync() - .split(_argumentSeparator) - .where((token) => token.isNotEmpty) - .toList(); - } on FileSystemException { - return null; - } - } -} diff --git a/lib/sync/firebase_account_store.dart b/lib/sync/firebase_account_store.dart new file mode 100644 index 0000000..adb39d6 --- /dev/null +++ b/lib/sync/firebase_account_store.dart @@ -0,0 +1,156 @@ +/// Where the per-device Firebase account and session live: the OS keystore. +/// +/// Split out of `firebase_backend.dart` for file size; that file re-exports +/// this one, so callers import it as before. +/// +/// Nothing here reads `~/.config/crdt-sync/` directly — that is the +/// desktop/Python half, reached over the local wrapper's HTTP routes. On +/// Android there is no such file. +library; + +import 'dart:convert'; +import 'dart:developer'; +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:todo/desktop/wrapper_server.dart' show kSyncCredentialsPath; + +/// Same options the GitHub token uses: off Android's deprecated +/// `encryptedSharedPreferences` path, and libsecret on Linux. +const _secure = FlutterSecureStorage(); + +// Everything below reaches the OS keystore through a platform channel, which +// `flutter test` has no binding for -- the same reason `main.dart` and +// `openRepository()` are excluded. The logic these wrap (parsing, the +// public/private split, sign-in) lives in `crdt_sync` and is covered there at +// 100%; what is left here is the two-line adapter. +// coverage:ignore-start + +/// The keystore-backed home for the Firebase refresh token. +SecureCredentialStore credentialStore() => SecureCredentialStore( + read: (key) => _secure.read(key: key), + write: (key, value) => _secure.write(key: key, value: value), + delete: (key) => _secure.delete(key: key), +); + +/// Reads the per-device account, or null when sync has not been set up. +/// +/// Falls back to the local wrapper when the store is empty, so a desktop +/// install can provision itself instead of the account being retyped per +/// machine. Tries the seeded Firebase session first (`/sync-credentials`, +/// `~/.config/todo/firebase_auth.json` via `seed_session.py --app todo`) -- +/// the only thing that actually authenticates, since the shared account's +/// password grant is retired fleet-wide (see `link_google.py`/ +/// `seed_session.py` in `~/utils/crdt-sync`) -- then the legacy +/// email/password route (`/sync-account`, `~/.config/crdt-sync/`) as a +/// fallback for a machine that has not been re-seeded yet. Whichever +/// succeeds is written to the store on first success, so the route is +/// consulted once rather than being load-bearing forever. +/// +/// [httpClient] is injectable for tests; production opens and closes its own +/// per attempted route. +Future loadAccount({http.Client? httpClient}) async { + try { + final stored = FirebaseAccount.tryParse( + await _secure.read(key: kFirebaseAccountKey), + ); + if (stored != null) return stored; + // Disconnect must stick: without this the next launch would silently + // re-adopt the account and the button would look broken. + if (await _secure.read(key: kSyncAccountOptOutKey) != null) return null; + final seeded = await _adoptCredentialsFromWrapper( + Uri.base, + client: httpClient, + ); + if (seeded != null) return seeded; + final provisioned = await accountFromWrapper(Uri.base, client: httpClient); + if (provisioned != null) await saveAccount(provisioned); + return provisioned; + // Broader than Exception on purpose: an Android build has no wrapper, so + // `Uri.base` is `file:///` and accountFromWrapper raises an ArgumentError + // ("No host specified in URI") -- an Error, which `on Exception` does not + // catch. That escaped openFirebase() and killed the whole sync tick, so a + // signed-in phone pulled nothing at all. + } on Object catch (error, stackTrace) { + // Still "not configured" rather than crashing the settings screen -- but + // never silent: this hid *why* provisioning failed, which is + // indistinguishable from "no account set" until you say so. + log( + 'loadAccount failed; treating this device as not configured', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return null; + } +} + +/// Fetches `todo`'s seeded session from the wrapper's `/sync-credentials` +/// route and adopts it: stores the refresh token in [credentialStore] and +/// writes an account marker so the Settings screen shows an address instead +/// of a blank. Returns the marker on success, null on any failure (route +/// disabled, file absent, malformed body, no wrapper at all) -- the caller's +/// fallback is simply "not configured". +/// +/// [client] is injectable for tests; production opens and closes its own. +Future _adoptCredentialsFromWrapper( + Uri base, { + http.Client? client, +}) async { + final httpClient = client ?? http.Client(); + try { + final response = await httpClient.get(base.resolve(kSyncCredentialsPath)); + if (response.statusCode != 200) return null; + final body = jsonDecode(utf8.decode(response.bodyBytes)); + if (body is! Map) return null; + if (body['id_token'] is! String || + body['refresh_token'] is! String || + body['expires_at'] is! String) { + return null; + } + await credentialStore().save(FirebaseCredentials.fromJson(body)); + final email = body['email']; + final account = FirebaseAccount( + email: email is String ? email : '', + password: '', + ); + await saveAccount(account); + return account; + } on Exception { + return null; + } finally { + if (client == null) httpClient.close(); + } +} + +/// Reads the account from the keystore only, with no wrapper fallback. +/// +/// [loadAccount] falls back to the desktop wrapper's `/sync-account` route +/// when the keystore is empty, which on Android resolves to `file:///` and +/// throws `No host specified in URI`. Callers that only want to read back an +/// account they just wrote — where a fallback would be wrong anyway — use +/// this instead. +Future storedAccount() async => + FirebaseAccount.tryParse(await _secure.read(key: kFirebaseAccountKey)); + +/// Stores the per-device account. Keystore only — never prefs, never source. +Future saveAccount(FirebaseAccount account) => + _secure.write(key: kFirebaseAccountKey, value: account.toJsonString()); + +/// Forgets the account and any cached session. +Future clearAccount() async { + await _secure.delete(key: kFirebaseAccountKey); + // Suppress wrapper re-provisioning; see loadAccount(). + await _secure.write(key: kSyncAccountOptOutKey, value: 'true'); + await credentialStore().clear(); +} + +/// Drops the account marker alone, leaving the opt-out flag unset. +/// +/// Used when a marker is found with no session behind it: not +/// [clearAccount], which also sets the opt-out flag and would stop the +/// desktop wrapper re-provisioning after the next sign-in. +Future clearAccountMarkerOnly() => + _secure.delete(key: kFirebaseAccountKey); + +// coverage:ignore-end diff --git a/lib/sync/firebase_backend.dart b/lib/sync/firebase_backend.dart index 4a5815b..f94a363 100644 --- a/lib/sync/firebase_backend.dart +++ b/lib/sync/firebase_backend.dart @@ -1,308 +1,24 @@ /// Wiring for the Firebase backend, during and after the GitHub cutover. /// -/// Split by what is safe to publish, because this repo is public: +/// A barrel over the three files this was split into for size. Import this one +/// and you get the whole backend API, exactly as before the split: /// -/// * [kProject] holds the Web API key and database URL. Both are public -/// identifiers that already ship inside the APK; the security rules, not -/// their secrecy, are what protect the data. -/// * The account email and password are entered once per device and kept in -/// the OS keystore, next to the GitHub token this app already stores there. +/// * `firebase_project.dart` — [kProject], the public project identifiers. +/// * `firebase_account_store.dart` — the account and refresh token, in the OS +/// keystore, plus the desktop wrapper's self-provisioning fallback. +/// * `firebase_client.dart` — opening a signed-in client, and reporting +/// whether this device can authenticate at all. +/// +/// Split by what is safe to publish, because this repo is public: the project +/// identifiers already ship inside the APK and are protected by the security +/// rules rather than by secrecy, while the account email and password are +/// entered once per device and kept in the OS keystore, next to the GitHub +/// token this app already stores there. /// /// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python /// half. On Android there is no such file. library; -import 'dart:convert'; -import 'dart:developer'; -import 'package:crdt_sync/crdt_sync.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; -import 'package:todo/desktop/wrapper_server.dart' show kSyncCredentialsPath; -import 'package:todo/sync/google_sign_in_backend.dart'; - -/// The shared `kuhy-syncs` project. -/// -/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form -/// answers 404 with a `correctUrl` body rather than an obvious error, which -/// reads like an auth failure and wastes a debugging session. -const kProject = FirebaseProject( - apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E', - databaseUrl: - 'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app', -); - -/// Same options the GitHub token uses: off Android's deprecated -/// `encryptedSharedPreferences` path, and libsecret on Linux. -const _secure = FlutterSecureStorage(); - -// Everything below reaches the OS keystore through a platform channel, which -// `flutter test` has no binding for -- the same reason `main.dart` and -// `openRepository()` are excluded. The logic these wrap (parsing, the -// public/private split, sign-in) lives in `crdt_sync` and is covered there at -// 100%; what is left here is the two-line adapter. -// coverage:ignore-start - -/// The keystore-backed home for the Firebase refresh token. -SecureCredentialStore credentialStore() => SecureCredentialStore( - read: (key) => _secure.read(key: key), - write: (key, value) => _secure.write(key: key, value: value), - delete: (key) => _secure.delete(key: key), -); - -/// Reads the per-device account, or null when sync has not been set up. -/// -/// Falls back to the local wrapper when the store is empty, so a desktop -/// install can provision itself instead of the account being retyped per -/// machine. Tries the seeded Firebase session first (`/sync-credentials`, -/// `~/.config/todo/firebase_auth.json` via `seed_session.py --app todo`) -- -/// the only thing that actually authenticates, since the shared account's -/// password grant is retired fleet-wide (see `link_google.py`/ -/// `seed_session.py` in `~/utils/crdt-sync`) -- then the legacy -/// email/password route (`/sync-account`, `~/.config/crdt-sync/`) as a -/// fallback for a machine that has not been re-seeded yet. Whichever -/// succeeds is written to the store on first success, so the route is -/// consulted once rather than being load-bearing forever. -/// -/// [httpClient] is injectable for tests; production opens and closes its own -/// per attempted route. -Future loadAccount({http.Client? httpClient}) async { - try { - final stored = FirebaseAccount.tryParse( - await _secure.read(key: kFirebaseAccountKey), - ); - if (stored != null) return stored; - // Disconnect must stick: without this the next launch would silently - // re-adopt the account and the button would look broken. - if (await _secure.read(key: kSyncAccountOptOutKey) != null) return null; - final seeded = await _adoptCredentialsFromWrapper( - Uri.base, - client: httpClient, - ); - if (seeded != null) return seeded; - final provisioned = await accountFromWrapper(Uri.base, client: httpClient); - if (provisioned != null) await saveAccount(provisioned); - return provisioned; - // Broader than Exception on purpose: an Android build has no wrapper, so - // `Uri.base` is `file:///` and accountFromWrapper raises an ArgumentError - // ("No host specified in URI") -- an Error, which `on Exception` does not - // catch. That escaped openFirebase() and killed the whole sync tick, so a - // signed-in phone pulled nothing at all. - } on Object catch (error, stackTrace) { - // Still "not configured" rather than crashing the settings screen -- but - // never silent: this hid *why* provisioning failed, which is - // indistinguishable from "no account set" until you say so. - log( - 'loadAccount failed; treating this device as not configured', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return null; - } -} - -/// Fetches `todo`'s seeded session from the wrapper's `/sync-credentials` -/// route and adopts it: stores the refresh token in [credentialStore] and -/// writes an account marker so the Settings screen shows an address instead -/// of a blank. Returns the marker on success, null on any failure (route -/// disabled, file absent, malformed body, no wrapper at all) -- the caller's -/// fallback is simply "not configured". -/// -/// [client] is injectable for tests; production opens and closes its own. -Future _adoptCredentialsFromWrapper( - Uri base, { - http.Client? client, -}) async { - final httpClient = client ?? http.Client(); - try { - final response = await httpClient.get(base.resolve(kSyncCredentialsPath)); - if (response.statusCode != 200) return null; - final body = jsonDecode(utf8.decode(response.bodyBytes)); - if (body is! Map) return null; - if (body['id_token'] is! String || - body['refresh_token'] is! String || - body['expires_at'] is! String) { - return null; - } - await credentialStore().save(FirebaseCredentials.fromJson(body)); - final email = body['email']; - final account = FirebaseAccount( - email: email is String ? email : '', - password: '', - ); - await saveAccount(account); - return account; - } on Exception { - return null; - } finally { - if (client == null) httpClient.close(); - } -} - -/// Reads the account from the keystore only, with no wrapper fallback. -/// -/// [loadAccount] falls back to the desktop wrapper's `/sync-account` route -/// when the keystore is empty, which on Android resolves to `file:///` and -/// throws `No host specified in URI`. Callers that only want to read back an -/// account they just wrote — where a fallback would be wrong anyway — use -/// this instead. -Future storedAccount() async => - FirebaseAccount.tryParse(await _secure.read(key: kFirebaseAccountKey)); - -/// Stores the per-device account. Keystore only — never prefs, never source. -Future saveAccount(FirebaseAccount account) => - _secure.write(key: kFirebaseAccountKey, value: account.toJsonString()); - -/// Forgets the account and any cached session. -Future clearAccount() async { - await _secure.delete(key: kFirebaseAccountKey); - // Suppress wrapper re-provisioning; see loadAccount(). - await _secure.write(key: kSyncAccountOptOutKey, value: 'true'); - await credentialStore().clear(); -} - -/// Returns a signed-in Firebase client, or null when not configured. -/// -/// Signs in with the stored password only when there is no cached refresh -/// token, so the usual path costs no authentication round trip. -Future openFirebase() async { - final account = await loadAccount(); - if (account == null) { - // A stored refresh token IS a signed-in device, even with no account - // marker beside it. Treating the marker as the source of truth is what - // made a phone with a live session sync over GitHub and 401 forever -- - // the credential was in the keystore the whole time, unused. - return _clientFromStoredSession(); - } - return firebaseClientFor( - config: kProject.configFor(account.email), - store: credentialStore(), - // A Google-provisioned account stores an empty password. Passing '' would - // make firebaseClientFor treat it as a usable credential and sign in with - // it, which fails; null correctly means "no password on this device". - password: account.password.isEmpty ? null : account.password, - // Deliberately NOT passing googleIdToken here. This path runs from - // background timers and, in some apps, before runApp -- offering Google - // would let a non-interactive tick raise the OS account picker with no - // user action behind it. Interactive sign-in goes through - // openFirebaseWithGoogle instead. - expectedUid: kSyncUid, - ); -} - -/// Signs in with Google alone, for a device that has no account stored yet. -/// -/// This is the one-tap path: [openFirebase] needs an account in the keystore -/// to know which email to use, but a fresh install has none. The Google token -/// carries the identity, so nothing needs to be typed -- and on success the -/// email is written to the keystore so every later launch takes the ordinary -/// path. -/// -/// Returns null when the user dismisses the picker or the token is refused; -/// throws [FirebaseAuthError] when Google succeeds but resolves to the wrong -/// uid, which is a misconfiguration worth surfacing rather than swallowing. -/// -/// On success the account is written to the keystore using the email -/// **Firebase reports**, not one read from the UI: a fresh install has no -/// email anywhere on the device, so taking it from a text field would persist -/// an empty account and send the next launch down the password path with ''. -Future openFirebaseWithGoogle({ - Future Function()? tokenFetcher, - Future Function(FirebaseAccount)? accountSaver, - http.Client? httpClient, -}) async { - final token = await (tokenFetcher ?? googleIdToken)(); - if (token == null) return null; - final auth = FirebaseTokenProvider( - apiKey: kProject.apiKey, - store: credentialStore(), - httpClient: httpClient, - ); - final email = await auth.signInWithGoogle( - idToken: token, - expectedUid: kSyncUid, - ); - // Saved unconditionally, and deliberately not gated on `email`: - // `signInWithIdp` omits that field whenever the Google account hides it, and - // gating the write on it returned a working client while persisting nothing, - // so the next launch looked unconfigured and fell back to GitHub-only. - // The session itself is already durable here -- signInWithGoogle stored the - // refresh token -- and that token, not the address, is the credential. - // password is empty on purpose: this device has a refresh token and no - // password to store. loadAccount() treats the account as configured, and - // openFirebase() re-signs-in with Google rather than with an empty - // password -- see the googleIdToken argument it passes. - await (accountSaver ?? saveAccount)( - FirebaseAccount(email: email ?? '', password: ''), - ); - return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); -} - -/// Whether this device can actually authenticate against Firebase. -/// -/// True when either half of the state is present: the account marker -/// [loadAccount] reads, or a stored refresh token. The token is the half that -/// matters -- it is what signs requests -- so reporting the marker alone calls -/// a working device "not connected", which is exactly how a phone that was in -/// fact syncing looked broken. -/// -/// The marker alone is not enough, though. A revoked refresh token makes the -/// library clear the session, and a marker left behind by the sign-in that -/// created it would still report "Connected" while every sync failed with -/// TOKEN_EXPIRED -- the desktop symptom seen on 2026-08-11. So when a marker -/// exists but the session is gone, the marker is the stale half: drop it. -Future isFirebaseConfigured() async { - try { - final auth = FirebaseTokenProvider( - apiKey: kProject.apiKey, - store: credentialStore(), - ); - if (await auth.hasSession()) return true; - // storedAccount, not loadAccount: on Android the latter falls back to the - // desktop wrapper's /sync-account route, which resolves to file:/// and - // throws "No host specified in URI" -- observed on the phone, where it - // turned a successful sign-in into "Google sign-in failed". - if (await storedAccount() == null) return false; - // A marker with no session behind it cannot sign a single request. Drop - // just the marker so the settings screen offers a sign-in instead of a - // dead banner -- not clearAccount(), which also sets the opt-out flag and - // would stop the desktop wrapper re-provisioning after the next sign-in. - await _secure.delete(key: kFirebaseAccountKey); - return false; - } on Object catch (error, stackTrace) { - log( - 'session probe failed; reporting this device as not configured', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return false; - } -} - -/// Returns a client built from the keystore's refresh token alone, or null. -/// -/// The recovery path for a device that has a live session but no account -/// marker -- the state a Google sign-in used to leave behind. Costs one -/// keystore read and no network round trip when there is no session, so it is -/// safe on the background-tick path [openFirebase] also serves. -Future _clientFromStoredSession() async { - try { - final auth = FirebaseTokenProvider( - apiKey: kProject.apiKey, - store: credentialStore(), - ); - if (!await auth.hasSession()) return null; - return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); - } on Object catch (error, stackTrace) { - log( - 'stored-session recovery failed; falling back to the GitHub mirror', - level: 1000, - error: error, - stackTrace: stackTrace, - ); - return null; - } -} - -// coverage:ignore-end +export 'firebase_account_store.dart'; +export 'firebase_client.dart'; +export 'firebase_project.dart'; diff --git a/lib/sync/firebase_client.dart b/lib/sync/firebase_client.dart new file mode 100644 index 0000000..55acd3c --- /dev/null +++ b/lib/sync/firebase_client.dart @@ -0,0 +1,162 @@ +/// Opening a signed-in Firebase client, and reporting whether one is possible. +/// +/// Split out of `firebase_backend.dart` for file size; that file re-exports +/// this one, so callers import it as before. The credentials these read live +/// in the OS keystore — see `firebase_account_store.dart`. +library; + +import 'dart:developer'; +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; +import 'package:todo/sync/firebase_account_store.dart'; +import 'package:todo/sync/firebase_project.dart'; +import 'package:todo/sync/google_sign_in_backend.dart'; + +// Reaches the OS keystore through a platform channel, which `flutter test` +// has no binding for; see the note in firebase_account_store.dart. +// coverage:ignore-start + +/// Returns a signed-in Firebase client, or null when not configured. +/// +/// Signs in with the stored password only when there is no cached refresh +/// token, so the usual path costs no authentication round trip. +Future openFirebase() async { + final account = await loadAccount(); + if (account == null) { + // A stored refresh token IS a signed-in device, even with no account + // marker beside it. Treating the marker as the source of truth is what + // made a phone with a live session sync over GitHub and 401 forever -- + // the credential was in the keystore the whole time, unused. + return _clientFromStoredSession(); + } + return firebaseClientFor( + config: kProject.configFor(account.email), + store: credentialStore(), + // A Google-provisioned account stores an empty password. Passing '' would + // make firebaseClientFor treat it as a usable credential and sign in with + // it, which fails; null correctly means "no password on this device". + password: account.password.isEmpty ? null : account.password, + // Deliberately NOT passing googleIdToken here. This path runs from + // background timers and, in some apps, before runApp -- offering Google + // would let a non-interactive tick raise the OS account picker with no + // user action behind it. Interactive sign-in goes through + // openFirebaseWithGoogle instead. + expectedUid: kSyncUid, + ); +} + +/// Signs in with Google alone, for a device that has no account stored yet. +/// +/// This is the one-tap path: [openFirebase] needs an account in the keystore +/// to know which email to use, but a fresh install has none. The Google token +/// carries the identity, so nothing needs to be typed -- and on success the +/// email is written to the keystore so every later launch takes the ordinary +/// path. +/// +/// Returns null when the user dismisses the picker or the token is refused; +/// throws [FirebaseAuthError] when Google succeeds but resolves to the wrong +/// uid, which is a misconfiguration worth surfacing rather than swallowing. +/// +/// On success the account is written to the keystore using the email +/// **Firebase reports**, not one read from the UI: a fresh install has no +/// email anywhere on the device, so taking it from a text field would persist +/// an empty account and send the next launch down the password path with ''. +Future openFirebaseWithGoogle({ + Future Function()? tokenFetcher, + Future Function(FirebaseAccount)? accountSaver, + http.Client? httpClient, +}) async { + final token = await (tokenFetcher ?? googleIdToken)(); + if (token == null) return null; + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + store: credentialStore(), + httpClient: httpClient, + ); + final email = await auth.signInWithGoogle( + idToken: token, + expectedUid: kSyncUid, + ); + // Saved unconditionally, and deliberately not gated on `email`: + // `signInWithIdp` omits that field whenever the Google account hides it, and + // gating the write on it returned a working client while persisting nothing, + // so the next launch looked unconfigured and fell back to GitHub-only. + // The session itself is already durable here -- signInWithGoogle stored the + // refresh token -- and that token, not the address, is the credential. + // password is empty on purpose: this device has a refresh token and no + // password to store. loadAccount() treats the account as configured, and + // openFirebase() re-signs-in with Google rather than with an empty + // password -- see the googleIdToken argument it passes. + await (accountSaver ?? saveAccount)( + FirebaseAccount(email: email ?? '', password: ''), + ); + return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); +} + +/// Whether this device can actually authenticate against Firebase. +/// +/// True when either half of the state is present: the account marker +/// [loadAccount] reads, or a stored refresh token. The token is the half that +/// matters -- it is what signs requests -- so reporting the marker alone calls +/// a working device "not connected", which is exactly how a phone that was in +/// fact syncing looked broken. +/// +/// The marker alone is not enough, though. A revoked refresh token makes the +/// library clear the session, and a marker left behind by the sign-in that +/// created it would still report "Connected" while every sync failed with +/// TOKEN_EXPIRED -- the desktop symptom seen on 2026-08-11. So when a marker +/// exists but the session is gone, the marker is the stale half: drop it. +Future isFirebaseConfigured() async { + try { + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + store: credentialStore(), + ); + if (await auth.hasSession()) return true; + // storedAccount, not loadAccount: on Android the latter falls back to the + // desktop wrapper's /sync-account route, which resolves to file:/// and + // throws "No host specified in URI" -- observed on the phone, where it + // turned a successful sign-in into "Google sign-in failed". + if (await storedAccount() == null) return false; + // A marker with no session behind it cannot sign a single request. Drop + // just the marker so the settings screen offers a sign-in instead of a + // dead banner. + await clearAccountMarkerOnly(); + return false; + } on Object catch (error, stackTrace) { + log( + 'session probe failed; reporting this device as not configured', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return false; + } +} + +/// Returns a client built from the keystore's refresh token alone, or null. +/// +/// The recovery path for a device that has a live session but no account +/// marker -- the state a Google sign-in used to leave behind. Costs one +/// keystore read and no network round trip when there is no session, so it is +/// safe on the background-tick path [openFirebase] also serves. +Future _clientFromStoredSession() async { + try { + final auth = FirebaseTokenProvider( + apiKey: kProject.apiKey, + store: credentialStore(), + ); + if (!await auth.hasSession()) return null; + return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); + } on Object catch (error, stackTrace) { + log( + 'stored-session recovery failed; falling back to the GitHub mirror', + level: 1000, + error: error, + stackTrace: stackTrace, + ); + return null; + } +} + +// coverage:ignore-end diff --git a/lib/sync/firebase_project.dart b/lib/sync/firebase_project.dart new file mode 100644 index 0000000..f70bba8 --- /dev/null +++ b/lib/sync/firebase_project.dart @@ -0,0 +1,24 @@ +/// The shared Firebase project identifiers. +/// +/// Its own file because both halves of the backend (the account store and the +/// client factory) need it, and neither should have to import the other. +/// +/// Safe to publish, and this repo is public: [kProject] holds the Web API key +/// and database URL, both public identifiers that already ship inside the APK. +/// The security rules, not their secrecy, are what protect the data. The +/// account credentials live in the OS keystore instead — see +/// `firebase_account_store.dart`. +library; + +import 'package:crdt_sync/crdt_sync.dart'; + +/// The shared `kuhy-syncs` project. +/// +/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form +/// answers 404 with a `correctUrl` body rather than an obvious error, which +/// reads like an auth failure and wastes a debugging session. +const kProject = FirebaseProject( + apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E', + databaseUrl: + 'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app', +); diff --git a/lib/ui/note_actions_sheet.dart b/lib/ui/note_actions_sheet.dart new file mode 100644 index 0000000..1af8a99 --- /dev/null +++ b/lib/ui/note_actions_sheet.dart @@ -0,0 +1,152 @@ +/// The per-note actions sheet shown from the notes list. +/// +/// Split out of `notes_list_screen.dart` for file size; public only because +/// Dart privacy is library-scoped. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_template.dart'; +import 'package:todo/ui/confirm.dart'; + +/// The per-note bottom sheet: status/priority chips, and delete. +class NoteActionsSheet extends StatefulWidget { + /// Creates the per-note actions sheet for [note]. + const NoteActionsSheet({ + required this.note, + required this.onChanged, + required this.onDelete, + super.key, + }); + + /// The note being acted on. + final Note note; + + /// Called with the edited note whenever a chip changes it. + final Future Function(Note) onChanged; + + /// Called once the user confirms deletion. + final Future Function() onDelete; + + @override + State createState() => _NoteActionsSheetState(); +} + +class _NoteActionsSheetState extends State { + late Priority _priority = widget.note.priority; + late Status _status = widget.note.status; + + Future _persist() async { + await widget.onChanged( + widget.note.copyWith( + priority: _priority, + status: _status, + updatedAt: DateTime.now(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final firstLine = noteTitle(widget.note.text); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + firstLine.isEmpty ? '(empty)' : firstLine, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + _EnumChips( + label: 'Status', + values: Status.values, + selected: {_status}, + labelOf: (s) => s.label, + onSelected: (s) { + setState(() => _status = s); + unawaited(_persist()); + }, + ), + const SizedBox(height: 12), + _EnumChips( + label: 'Priority', + values: Priority.values, + selected: {_priority}, + labelOf: (p) => p.label, + onSelected: (p) { + setState(() => _priority = p); + unawaited(_persist()); + }, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + icon: const Icon(Icons.delete_outline), + label: const Text('Delete note'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () async { + final confirmed = await confirmDestructive( + context, + title: 'Delete note?', + message: 'This cannot be undone.', + ); + if (!confirmed) return; + await widget.onDelete(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + } +} + +/// Single-select chip row for an enum (used for per-note priority/status). +class _EnumChips extends StatelessWidget { + const _EnumChips({ + required this.label, + required this.values, + required this.selected, + required this.labelOf, + required this.onSelected, + }); + + final String label; + final List values; + final Set selected; + final String Function(T) labelOf; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + for (final v in values) + ChoiceChip( + label: Text(labelOf(v)), + selected: selected.contains(v), + onSelected: (_) => onSelected(v), + ), + ], + ), + ], + ); + } +} diff --git a/lib/ui/note_date_range_field.dart b/lib/ui/note_date_range_field.dart new file mode 100644 index 0000000..257ebc5 --- /dev/null +++ b/lib/ui/note_date_range_field.dart @@ -0,0 +1,109 @@ +/// The Created / Last-updated date-range control used by the filter sheet. +/// +/// Split out of `notes_list_screen.dart` for file size. Public only because +/// Dart privacy is library-scoped and the filter sheet now lives in a +/// different file. +library; + +import 'package:flutter/material.dart'; + +/// A labelled date range shown as presets plus a custom-range picker. +class DateRangeField extends StatelessWidget { + /// Creates a labelled date-range control. + const DateRangeField({ + required this.label, + required this.from, + required this.to, + required this.onChanged, + super.key, + }); + + /// Heading shown above the control (e.g. 'Created'). + final String label; + + /// Start of the selected range, or null when unbounded. + final DateTime? from; + + /// End of the selected range, or null when unbounded. + final DateTime? to; + + /// Called with the new (from, to); either may be null to clear. + final void Function(DateTime? from, DateTime? to) onChanged; + + bool get _hasRange => from != null || to != null; + + /// Sets a preset range of the last [days] days ending today. + void _applyDays(int days) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + onChanged(today.subtract(Duration(days: days - 1)), today); + } + + Future _pickCustom(BuildContext context) async { + final now = DateTime.now(); + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(now.year + 1, 12, 31), + initialDateRange: (from != null && to != null) + ? DateTimeRange(start: from!, end: to!) + : null, + ); + if (picked != null) onChanged(picked.start, picked.end); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(label, style: theme.textTheme.labelLarge), + const Spacer(), + if (_hasRange) + Text(_rangeLabel(), style: theme.textTheme.bodySmall), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + ActionChip( + label: const Text('Today'), + onPressed: () => _applyDays(1), + ), + ActionChip( + label: const Text('7 days'), + onPressed: () => _applyDays(7), + ), + ActionChip( + label: const Text('30 days'), + onPressed: () => _applyDays(30), + ), + ActionChip( + label: const Text('Custom…'), + onPressed: () => _pickCustom(context), + ), + if (_hasRange) + ActionChip( + avatar: const Icon(Icons.clear, size: 16), + label: const Text('Any'), + onPressed: () => onChanged(null, null), + ), + ], + ), + ], + ); + } + + /// Compact "YYYY-MM-DD → YYYY-MM-DD" (or one-sided) summary of the range. + String _rangeLabel() { + String d(DateTime? t) => + t == null ? '…' : '${t.year}-${_two(t.month)}-${_two(t.day)}'; + return '${d(from)} → ${d(to)}'; + } + + String _two(int n) => n.toString().padLeft(2, '0'); +} diff --git a/lib/ui/note_tile.dart b/lib/ui/note_tile.dart new file mode 100644 index 0000000..e088a9d --- /dev/null +++ b/lib/ui/note_tile.dart @@ -0,0 +1,69 @@ +/// One row of the notes list. +/// +/// Split out of `notes_list_screen.dart` for file size; public only because +/// Dart privacy is library-scoped. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_template.dart'; + +/// One row in the notes list: title, metadata line, and an actions button. +class NoteTile extends StatelessWidget { + /// Creates a tile for [note]. + const NoteTile({ + required this.note, + required this.onTap, + required this.onActions, + super.key, + }); + + /// The note this row displays. + final Note note; + + /// Open the full note for reading/editing. + /// Called when the row body is tapped (opens the note). + final VoidCallback onTap; + + /// Open the quick-actions sheet (priority/status/delete). + /// Called when the trailing actions button is tapped. + final VoidCallback onActions; + + @override + Widget build(BuildContext context) { + final firstLine = noteTitle(note.text); + // Every note has a status and a priority now, so both are always shown. + final meta = [ + note.status.label, + note.priority.label, + 'edited ${_relative(note.updatedAt)}', + ].join(' · '); + return ListTile( + title: Text( + firstLine.isEmpty ? '(empty)' : firstLine, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(meta), + trailing: IconButton( + icon: const Icon(Icons.more_vert), + tooltip: 'Quick actions', + onPressed: onActions, + ), + onTap: onTap, + onLongPress: onActions, + ); + } + + /// Compact relative time like "2m ago" for the list subtitle. + String _relative(DateTime t) { + final d = DateTime.now().difference(t); + if (d.inMinutes < 1) return 'just now'; + if (d.inHours < 1) return '${d.inMinutes}m ago'; + if (d.inDays < 1) return '${d.inHours}h ago'; + return '${d.inDays}d ago'; + } +} + +/// Bottom sheet for editing one note's priority/status or deleting it. diff --git a/lib/ui/notes_filter_sheet.dart b/lib/ui/notes_filter_sheet.dart new file mode 100644 index 0000000..8a4eea6 --- /dev/null +++ b/lib/ui/notes_filter_sheet.dart @@ -0,0 +1,184 @@ +/// The notes-list filter sheet and its multi-select chip row. +/// +/// Split out of `notes_list_screen.dart` for file size. [FilterSheet] is +/// public only because Dart privacy is library-scoped; it is still an +/// implementation detail of the notes list, pushed by +/// `_NotesListScreenState._openFilters`. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/ui/note_date_range_field.dart'; +import 'package:todo/ui/notes_list_screen.dart' show kDefaultStatuses; + +/// The filter editing sheet: priority + status multi-select and Created / +/// Last-updated date ranges (presets + a custom range picker). Edits a +/// working copy and returns it via [Navigator.pop] on "Apply". +class FilterSheet extends StatefulWidget { + /// Creates the sheet seeded with the currently applied [initial] filter. + const FilterSheet({required this.initial, super.key}); + + /// The filter the sheet opens with; edits are made to a working copy. + final NoteFilter initial; + + @override + State createState() => _FilterSheetState(); +} + +class _FilterSheetState extends State { + late Set _priorities = {...widget.initial.priorities}; + late Set _statuses = {...widget.initial.statuses}; + late DateTime? _createdFrom = widget.initial.createdFrom; + late DateTime? _createdTo = widget.initial.createdTo; + late DateTime? _updatedFrom = widget.initial.updatedFrom; + late DateTime? _updatedTo = widget.initial.updatedTo; + + void _toggle(Set set, T value) { + setState(() => set.contains(value) ? set.remove(value) : set.add(value)); + } + + void _clearAll() { + setState(() { + _priorities = {}; + // Reset to the default view (hide Done/Abandoned), not an empty set, + // so "Clear all" matches the app's unfiltered baseline. + _statuses = {...kDefaultStatuses}; + _createdFrom = null; + _createdTo = null; + _updatedFrom = null; + _updatedTo = null; + }); + } + + NoteFilter _build() { + // query is owned by the search box and re-applied by the caller. + return NoteFilter( + priorities: _priorities, + statuses: _statuses, + createdFrom: _createdFrom, + createdTo: _createdTo, + updatedFrom: _updatedFrom, + updatedTo: _updatedTo, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Filters', style: theme.textTheme.titleLarge), + const Spacer(), + TextButton( + onPressed: _clearAll, + child: const Text('Clear all'), + ), + ], + ), + const SizedBox(height: 8), + _MultiChips( + label: 'Status', + values: Status.values, + selected: _statuses, + labelOf: (s) => s.label, + onToggle: (s) => _toggle(_statuses, s), + ), + const SizedBox(height: 12), + _MultiChips( + label: 'Priority', + values: Priority.values, + selected: _priorities, + labelOf: (p) => p.label, + onToggle: (p) => _toggle(_priorities, p), + ), + const SizedBox(height: 12), + DateRangeField( + label: 'Created', + from: _createdFrom, + to: _createdTo, + onChanged: (from, to) => setState(() { + _createdFrom = from; + _createdTo = to; + }), + ), + const SizedBox(height: 12), + DateRangeField( + label: 'Last updated', + from: _updatedFrom, + to: _updatedTo, + onChanged: (from, to) => setState(() { + _updatedFrom = from; + _updatedTo = to; + }), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => Navigator.of(context).pop(_build()), + child: const Text('Apply'), + ), + ], + ), + ), + ), + ); + } +} + +/// Multi-select chip group for an enum (used by the filter sheet). +class _MultiChips extends StatelessWidget { + const _MultiChips({ + required this.label, + required this.values, + required this.selected, + required this.labelOf, + required this.onToggle, + }); + + final String label; + final List values; + final Set selected; + final String Function(T) labelOf; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + for (final v in values) + FilterChip( + label: Text(labelOf(v)), + selected: selected.contains(v), + onSelected: (_) => onToggle(v), + ), + ], + ), + ], + ); + } +} + +/// A date-range control offering quick presets plus a custom range picker. +/// +/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to +/// the parent; `null`/`null` means "any date" for this field. diff --git a/lib/ui/notes_list_navigation.dart b/lib/ui/notes_list_navigation.dart new file mode 100644 index 0000000..8bb16d2 --- /dev/null +++ b/lib/ui/notes_list_navigation.dart @@ -0,0 +1,52 @@ +/// Navigation out of the notes list: the detail screen and the actions sheet. +/// +/// Split out of `notes_list_screen.dart` for file size. Plain functions rather +/// than methods because neither needs the screen's state -- they only need a +/// context, the note, and the store. +library; + +import 'package:flutter/material.dart'; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/ui/note_actions_sheet.dart'; +import 'package:todo/ui/note_detail_screen.dart'; + +/// Opens the full note: read it, edit the body, change priority/status. +Future openNote( + BuildContext context, + Note note, + NoteRepository repository, +) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NoteDetailScreen(note: note, repository: repository), + ), + ); +} + +/// Opens the per-note quick-actions sheet (priority, status, delete). +Future openNoteActions( + BuildContext context, + Note note, + NoteRepository repository, +) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + // Scroll-controlled so the sheet can exceed the default 9/16 of the + // viewport. On a 768px-tall screen that default is only ~432px, and this + // sheet's content grows with the note title and its chip rows -- an + // unscrollable sheet would clip the Delete action out of reach. + isScrollControlled: true, + builder: (_) => NoteActionsSheet( + note: note, + onChanged: (updated) async { + await repository.upsert(updated); + }, + onDelete: () async { + await repository.delete(note.id); + }, + ), + ); +} diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index a7200f8..b7b1c0e 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; -import 'package:todo/data/note_template.dart'; -import 'package:todo/ui/confirm.dart'; -import 'package:todo/ui/note_detail_screen.dart'; +import 'package:todo/ui/note_tile.dart'; +import 'package:todo/ui/notes_filter_sheet.dart'; +import 'package:todo/ui/notes_list_navigation.dart'; /// The default status selection: hide completed/dropped work. This is the /// app's notion of "unfiltered", so it does not count towards the filter @@ -103,7 +103,7 @@ class _NotesListScreenState extends State { context: context, isScrollControlled: true, showDragHandle: true, - builder: (_) => _FilterSheet(initial: _filter), + builder: (_) => FilterSheet(initial: _filter), ); if (edited != null) { _filter = edited.copyWith(query: _searchController.text); @@ -111,38 +111,6 @@ class _NotesListScreenState extends State { } } - /// Opens the full note: read it, edit the body, change priority/status. - Future _openNote(Note note) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - NoteDetailScreen(note: note, repository: widget.repository), - ), - ); - } - - /// Opens the per-note quick-actions sheet (priority, status, delete). - Future _openNoteActions(Note note) async { - await showModalBottomSheet( - context: context, - showDragHandle: true, - // Scroll-controlled so the sheet can exceed the default 9/16 of the - // viewport. On a 768px-tall screen that default is only ~432px, and this - // sheet's content grows with the note title and its chip rows -- an - // unscrollable sheet would clip the Delete action out of reach. - isScrollControlled: true, - builder: (_) => _NoteActionsSheet( - note: note, - onChanged: (updated) async { - await widget.repository.upsert(updated); - }, - onDelete: () async { - await widget.repository.delete(note.id); - }, - ), - ); - } - @override Widget build(BuildContext context) { final badgeCount = _badgeCount; @@ -236,11 +204,13 @@ class _NotesListScreenState extends State { return ListView.separated( itemCount: notes.length, separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) => _NoteTile( + itemBuilder: (context, i) => NoteTile( key: ValueKey(notes[i].id), note: notes[i], - onTap: () => _openNote(notes[i]), - onActions: () => _openNoteActions(notes[i]), + onTap: () => + openNote(context, notes[i], widget.repository), + onActions: () => + openNoteActions(context, notes[i], widget.repository), ), ); }, @@ -253,446 +223,3 @@ class _NotesListScreenState extends State { } /// One row in the notes list: first line, then a metadata subtitle. -class _NoteTile extends StatelessWidget { - const _NoteTile({ - required this.note, - required this.onTap, - required this.onActions, - super.key, - }); - - final Note note; - - /// Open the full note for reading/editing. - final VoidCallback onTap; - - /// Open the quick-actions sheet (priority/status/delete). - final VoidCallback onActions; - - @override - Widget build(BuildContext context) { - final firstLine = noteTitle(note.text); - // Every note has a status and a priority now, so both are always shown. - final meta = [ - note.status.label, - note.priority.label, - 'edited ${_relative(note.updatedAt)}', - ].join(' · '); - return ListTile( - title: Text( - firstLine.isEmpty ? '(empty)' : firstLine, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text(meta), - trailing: IconButton( - icon: const Icon(Icons.more_vert), - tooltip: 'Quick actions', - onPressed: onActions, - ), - onTap: onTap, - onLongPress: onActions, - ); - } - - /// Compact relative time like "2m ago" for the list subtitle. - String _relative(DateTime t) { - final d = DateTime.now().difference(t); - if (d.inMinutes < 1) return 'just now'; - if (d.inHours < 1) return '${d.inMinutes}m ago'; - if (d.inDays < 1) return '${d.inHours}h ago'; - return '${d.inDays}d ago'; - } -} - -/// Bottom sheet for editing one note's priority/status or deleting it. -class _NoteActionsSheet extends StatefulWidget { - const _NoteActionsSheet({ - required this.note, - required this.onChanged, - required this.onDelete, - }); - - final Note note; - final Future Function(Note) onChanged; - final Future Function() onDelete; - - @override - State<_NoteActionsSheet> createState() => _NoteActionsSheetState(); -} - -class _NoteActionsSheetState extends State<_NoteActionsSheet> { - late Priority _priority = widget.note.priority; - late Status _status = widget.note.status; - - Future _persist() async { - await widget.onChanged( - widget.note.copyWith( - priority: _priority, - status: _status, - updatedAt: DateTime.now(), - ), - ); - } - - @override - Widget build(BuildContext context) { - final firstLine = noteTitle(widget.note.text); - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - firstLine.isEmpty ? '(empty)' : firstLine, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 16), - _EnumChips( - label: 'Status', - values: Status.values, - selected: {_status}, - labelOf: (s) => s.label, - onSelected: (s) { - setState(() => _status = s); - unawaited(_persist()); - }, - ), - const SizedBox(height: 12), - _EnumChips( - label: 'Priority', - values: Priority.values, - selected: {_priority}, - labelOf: (p) => p.label, - onSelected: (p) { - setState(() => _priority = p); - unawaited(_persist()); - }, - ), - const SizedBox(height: 16), - OutlinedButton.icon( - icon: const Icon(Icons.delete_outline), - label: const Text('Delete note'), - style: OutlinedButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, - ), - onPressed: () async { - final confirmed = await confirmDestructive( - context, - title: 'Delete note?', - message: 'This cannot be undone.', - ); - if (!confirmed) return; - await widget.onDelete(); - if (context.mounted) Navigator.of(context).pop(); - }, - ), - ], - ), - ), - ); - } -} - -/// Single-select chip row for an enum (used for per-note priority/status). -class _EnumChips extends StatelessWidget { - const _EnumChips({ - required this.label, - required this.values, - required this.selected, - required this.labelOf, - required this.onSelected, - }); - - final String label; - final List values; - final Set selected; - final String Function(T) labelOf; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 6), - Wrap( - spacing: 8, - children: [ - for (final v in values) - ChoiceChip( - label: Text(labelOf(v)), - selected: selected.contains(v), - onSelected: (_) => onSelected(v), - ), - ], - ), - ], - ); - } -} - -/// The filter editing sheet: priority + status multi-select and Created / -/// Last-updated date ranges (presets + a custom range picker). Edits a -/// working copy and returns it via [Navigator.pop] on "Apply". -class _FilterSheet extends StatefulWidget { - const _FilterSheet({required this.initial}); - - final NoteFilter initial; - - @override - State<_FilterSheet> createState() => _FilterSheetState(); -} - -class _FilterSheetState extends State<_FilterSheet> { - late Set _priorities = {...widget.initial.priorities}; - late Set _statuses = {...widget.initial.statuses}; - late DateTime? _createdFrom = widget.initial.createdFrom; - late DateTime? _createdTo = widget.initial.createdTo; - late DateTime? _updatedFrom = widget.initial.updatedFrom; - late DateTime? _updatedTo = widget.initial.updatedTo; - - void _toggle(Set set, T value) { - setState(() => set.contains(value) ? set.remove(value) : set.add(value)); - } - - void _clearAll() { - setState(() { - _priorities = {}; - // Reset to the default view (hide Done/Abandoned), not an empty set, - // so "Clear all" matches the app's unfiltered baseline. - _statuses = {...kDefaultStatuses}; - _createdFrom = null; - _createdTo = null; - _updatedFrom = null; - _updatedTo = null; - }); - } - - NoteFilter _build() { - // query is owned by the search box and re-applied by the caller. - return NoteFilter( - priorities: _priorities, - statuses: _statuses, - createdFrom: _createdFrom, - createdTo: _createdTo, - updatedFrom: _updatedFrom, - updatedTo: _updatedTo, - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SafeArea( - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - 16 + MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Filters', style: theme.textTheme.titleLarge), - const Spacer(), - TextButton( - onPressed: _clearAll, - child: const Text('Clear all'), - ), - ], - ), - const SizedBox(height: 8), - _MultiChips( - label: 'Status', - values: Status.values, - selected: _statuses, - labelOf: (s) => s.label, - onToggle: (s) => _toggle(_statuses, s), - ), - const SizedBox(height: 12), - _MultiChips( - label: 'Priority', - values: Priority.values, - selected: _priorities, - labelOf: (p) => p.label, - onToggle: (p) => _toggle(_priorities, p), - ), - const SizedBox(height: 12), - _DateRangeField( - label: 'Created', - from: _createdFrom, - to: _createdTo, - onChanged: (from, to) => setState(() { - _createdFrom = from; - _createdTo = to; - }), - ), - const SizedBox(height: 12), - _DateRangeField( - label: 'Last updated', - from: _updatedFrom, - to: _updatedTo, - onChanged: (from, to) => setState(() { - _updatedFrom = from; - _updatedTo = to; - }), - ), - const SizedBox(height: 20), - FilledButton( - onPressed: () => Navigator.of(context).pop(_build()), - child: const Text('Apply'), - ), - ], - ), - ), - ), - ); - } -} - -/// Multi-select chip group for an enum (used by the filter sheet). -class _MultiChips extends StatelessWidget { - const _MultiChips({ - required this.label, - required this.values, - required this.selected, - required this.labelOf, - required this.onToggle, - }); - - final String label; - final List values; - final Set selected; - final String Function(T) labelOf; - final ValueChanged onToggle; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: 6), - Wrap( - spacing: 8, - children: [ - for (final v in values) - FilterChip( - label: Text(labelOf(v)), - selected: selected.contains(v), - onSelected: (_) => onToggle(v), - ), - ], - ), - ], - ); - } -} - -/// A date-range control offering quick presets plus a custom range picker. -/// -/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to -/// the parent; `null`/`null` means "any date" for this field. -class _DateRangeField extends StatelessWidget { - const _DateRangeField({ - required this.label, - required this.from, - required this.to, - required this.onChanged, - }); - - final String label; - final DateTime? from; - final DateTime? to; - - /// Called with the new (from, to); either may be null to clear. - final void Function(DateTime? from, DateTime? to) onChanged; - - bool get _hasRange => from != null || to != null; - - /// Sets a preset range of the last [days] days ending today. - void _applyDays(int days) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - onChanged(today.subtract(Duration(days: days - 1)), today); - } - - Future _pickCustom(BuildContext context) async { - final now = DateTime.now(); - final picked = await showDateRangePicker( - context: context, - firstDate: DateTime(2020), - lastDate: DateTime(now.year + 1, 12, 31), - initialDateRange: (from != null && to != null) - ? DateTimeRange(start: from!, end: to!) - : null, - ); - if (picked != null) onChanged(picked.start, picked.end); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(label, style: theme.textTheme.labelLarge), - const Spacer(), - if (_hasRange) - Text(_rangeLabel(), style: theme.textTheme.bodySmall), - ], - ), - const SizedBox(height: 6), - Wrap( - spacing: 8, - children: [ - ActionChip( - label: const Text('Today'), - onPressed: () => _applyDays(1), - ), - ActionChip( - label: const Text('7 days'), - onPressed: () => _applyDays(7), - ), - ActionChip( - label: const Text('30 days'), - onPressed: () => _applyDays(30), - ), - ActionChip( - label: const Text('Custom…'), - onPressed: () => _pickCustom(context), - ), - if (_hasRange) - ActionChip( - avatar: const Icon(Icons.clear, size: 16), - label: const Text('Any'), - onPressed: () => onChanged(null, null), - ), - ], - ), - ], - ); - } - - /// Compact "YYYY-MM-DD → YYYY-MM-DD" (or one-sided) summary of the range. - String _rangeLabel() { - String d(DateTime? t) => - t == null ? '…' : '${t.year}-${_two(t.month)}-${_two(t.day)}'; - return '${d(from)} → ${d(to)}'; - } - - String _two(int n) => n.toString().padLeft(2, '0'); -} diff --git a/pubspec.yaml b/pubspec.yaml index 15001e0..e7680e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.10+1 +version: 1.0.11+1 environment: sdk: ^3.12.2 diff --git a/scripts/check_file_length.sh b/scripts/check_file_length.sh new file mode 100755 index 0000000..45d8370 --- /dev/null +++ b/scripts/check_file_length.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# ============================================================================ +# Fail if any file in the commit exceeds the shared 250-line cap. +# +# Thin delegate to the shared gate in ~/utils, which owns the cap and the +# exemption list (generated / vendored / data files). Copying that logic here +# is what lets one repo's idea of "too long" drift from every other repo's -- +# so this script only locates the shared checker and forwards its arguments. +# +# Usage: +# scripts/check_file_length.sh [ ...] # pre-commit passes these +# scripts/check_file_length.sh --all # whole tree, from cwd +# ============================================================================ + +set -euo pipefail + +readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_file_length.sh" + +main() { + if [[ ! -x "$SHARED_GATE" ]]; then + echo "Error: shared file-length gate not found at $SHARED_GATE" >&2 + echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2 + echo " UTILS_ROOT to where it lives." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@" diff --git a/scripts/install_hooks.sh b/scripts/install_hooks.sh index 23fc2b2..8f46812 100755 --- a/scripts/install_hooks.sh +++ b/scripts/install_hooks.sh @@ -4,8 +4,14 @@ # Install this repo's git hooks. # # .git/hooks/ is not tracked, so a fresh clone has no hooks. Run this once -# after cloning. Unlike habit_stack and diet-guard, this repo does not use the -# pre-commit framework (its quality gate is CI), so the hook is a plain script. +# after cloning. +# +# This repo used to write a hand-rolled .git/hooks/pre-commit here. It moved +# to the pre-commit framework when the 250-line file-length gate was added: +# three checks in one hand-written hook is the point where "just a plain +# script" stops paying for itself, and pre-commit already solves passing only +# the staged files to each check. The checks themselves are unchanged and +# still live in scripts/ -- see .pre-commit-config.yaml. # ============================================================================ set -euo pipefail @@ -13,27 +19,36 @@ set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" readonly REPO_ROOT readonly HOOK="$REPO_ROOT/.git/hooks/pre-commit" +readonly LEGACY_MARKER='bump_patch_version.sh' + +# The old hand-written hook has to go before pre-commit will install over it; +# pre-commit refuses to clobber a foreign hook, and leaving it would run the +# version bump twice per commit. +remove_legacy_hook() { + if [[ -e "$HOOK" ]] && grep -q "$LEGACY_MARKER" "$HOOK" \ + && ! grep -q 'pre-commit' "$HOOK"; then + rm -f "$HOOK" + echo "Removed the legacy hand-written pre-commit hook." + fi +} + +ensure_pre_commit() { + if command -v pre-commit >/dev/null 2>&1; then + return + fi + echo "Installing pre-commit..." + if command -v pipx >/dev/null 2>&1; then + pipx install pre-commit + else + python3 -m pip install --user pre-commit + fi +} main() { - if [[ -e "$HOOK" ]] && ! grep -q 'bump_patch_version.sh' "$HOOK"; then - echo "Error: $HOOK exists and is not ours; refusing to clobber it." >&2 - exit 1 - fi - - cat > "$HOOK" <<'HOOK_BODY' -#!/bin/bash -# Installed by scripts/install_hooks.sh — see that file. -set -euo pipefail -readonly ROOT="$(git rev-parse --show-toplevel)" -# Gate first: a local crdt_sync override left in place makes CI build a -# library this checkout has never tested against, silently and in both -# directions. Blocking the commit is the only thing that actually prevents it. -bash "$ROOT/scripts/check_no_crdt_sync_override.sh" "$ROOT/pubspec.yaml" -exec bash "$ROOT/scripts/bump_patch_version.sh" -HOOK_BODY - - chmod 755 "$HOOK" - echo "Installed pre-commit hook: $HOOK" + ensure_pre_commit + remove_legacy_hook + pre-commit install + echo "Installed pre-commit hooks: $HOOK" } main "$@"