Enforce the 250-line file cap, and split the first four files

Wires the shared gate (~/utils/scripts/check_file_length.sh) into a new
.pre-commit-config.yaml as a local hook, checking only the files in the
commit and failing rather than warning. No baseline, no allowlist.

The repo had a hand-written .git/hooks/pre-commit running two checks;
both are ported verbatim into the config rather than dropped, with the
version bump ordered last so it cannot rewrite pubspec.yaml for a commit
a gate is still about to reject. scripts/install_hooks.sh now installs
pre-commit (and removes the legacy hook) instead of writing its own.

.github/workflows/file-length.yml runs the same check on push, kept
separate from release-apk.yml so an over-long file fails lint rather than
blocking an APK release.

Splits the four cleanest violations, all behaviour-preserving:

  note_template.dart      370 -> 203 + parser (186)
  firebase_backend.dart   308 -> barrel (24) + project/store/client
  port_guard.dart         315 -> 232 + linux_system_probe (100)
  notes_list_screen.dart  698 -> 225 + 5 widget/nav files

Where a file had external callers, the original path stays as a barrel
that re-exports the split parts, so no call site changed.

Verified: 313 tests pass, coverage identical at 99.9% / 1922 lines
(per-file too -- notes_list's 281 covered lines are now 81+75+51+41+22+11),
flutter analyze clean, and a staged 260-line file makes git commit fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 16:01:24 +02:00
parent 2c97a9c19c
commit 9d2aa1c178
19 changed files with 1379 additions and 1070 deletions

33
.github/workflows/file-length.yml vendored Normal file
View File

@ -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

43
.pre-commit-config.yaml Normal file
View File

@ -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

View File

@ -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<String, String> 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<String, String> values) {
if (template.isFreeform) return (values['body'] ?? '').trimRight();
final blocks = <String>[];
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 = <String, int>{};
var oi = 0;
for (final s in template.sections) {
if (!s.isTitle) order[s.key] = oi++;
}
final values = <String, String>{for (final s in template.sections) s.key: ''};
final lines = text.split('\n');
final titleLines = <String>[];
String? currentKey; // open section accumulating its block lines
final blocks = <String, List<String>>{};
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] = <String>[];
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<String> lines) {
final out = List<String>.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');
}

View File

@ -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<String, String> 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<String, String> values) {
if (template.isFreeform) return (values['body'] ?? '').trimRight();
final blocks = <String>[];
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 = <String, int>{};
var oi = 0;
for (final s in template.sections) {
if (!s.isTitle) order[s.key] = oi++;
}
final values = <String, String>{for (final s in template.sections) s.key: ''};
final lines = text.split('\n');
final titleLines = <String>[];
String? currentKey; // open section accumulating its block lines
final blocks = <String, List<String>>{};
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] = <String>[];
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<String> lines) {
final out = List<String>.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');
}

View File

@ -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/<pid>/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<int?> 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<ProcessDescription?> 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<List<List<String>>> processCmdlines() async {
final cmdlines = <List<String>>[];
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<bool> 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/<pid>/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<String>? readCmdline(int pid) {
try {
return File('/proc/$pid/cmdline')
.readAsStringSync()
.split(_argumentSeparator)
.where((token) => token.isNotEmpty)
.toList();
} on FileSystemException {
return null;
}
}
}

View File

@ -4,8 +4,11 @@ import 'dart:io';
import 'package:path/path.dart' as p;
/// NUL separates the arguments inside `/proc/<pid>/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<int?> 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<ProcessDescription?> 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<List<List<String>>> processCmdlines() async {
final cmdlines = <List<String>>[];
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<bool> 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/<pid>/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<String>? readCmdline(int pid) {
try {
return File('/proc/$pid/cmdline')
.readAsStringSync()
.split(_argumentSeparator)
.where((token) => token.isNotEmpty)
.toList();
} on FileSystemException {
return null;
}
}
}

View File

@ -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<FirebaseAccount?> 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<FirebaseAccount?> _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<String, dynamic>) 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<FirebaseAccount?> storedAccount() async =>
FirebaseAccount.tryParse(await _secure.read(key: kFirebaseAccountKey));
/// Stores the per-device account. Keystore only — never prefs, never source.
Future<void> saveAccount(FirebaseAccount account) =>
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
/// Forgets the account and any cached session.
Future<void> 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<void> clearAccountMarkerOnly() =>
_secure.delete(key: kFirebaseAccountKey);
// coverage:ignore-end

View File

@ -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<FirebaseAccount?> 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<FirebaseAccount?> _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<String, dynamic>) 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<FirebaseAccount?> storedAccount() async =>
FirebaseAccount.tryParse(await _secure.read(key: kFirebaseAccountKey));
/// Stores the per-device account. Keystore only — never prefs, never source.
Future<void> saveAccount(FirebaseAccount account) =>
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
/// Forgets the account and any cached session.
Future<void> 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<FirebaseRestClient?> 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<FirebaseRestClient?> openFirebaseWithGoogle({
Future<String?> Function()? tokenFetcher,
Future<void> 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<bool> 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<FirebaseRestClient?> _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';

View File

@ -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<FirebaseRestClient?> 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<FirebaseRestClient?> openFirebaseWithGoogle({
Future<String?> Function()? tokenFetcher,
Future<void> 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<bool> 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<FirebaseRestClient?> _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

View File

@ -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',
);

View File

@ -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<void> Function(Note) onChanged;
/// Called once the user confirms deletion.
final Future<void> 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<void> _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<Status>(
label: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
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<T> extends StatelessWidget {
const _EnumChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onSelected,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> 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),
),
],
),
],
);
}
}

View File

@ -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<void> _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');
}

69
lib/ui/note_tile.dart Normal file
View File

@ -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 = <String>[
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.

View File

@ -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<FilterSheet> createState() => _FilterSheetState();
}
class _FilterSheetState extends State<FilterSheet> {
late Set<Priority> _priorities = {...widget.initial.priorities};
late Set<Status> _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<T>(Set<T> 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<Status>(
label: 'Status',
values: Status.values,
selected: _statuses,
labelOf: (s) => s.label,
onToggle: (s) => _toggle(_statuses, s),
),
const SizedBox(height: 12),
_MultiChips<Priority>(
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<T> extends StatelessWidget {
const _MultiChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onToggle,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> 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.

View File

@ -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<void> openNote(
BuildContext context,
Note note,
NoteRepository repository,
) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => NoteDetailScreen(note: note, repository: repository),
),
);
}
/// Opens the per-note quick-actions sheet (priority, status, delete).
Future<void> openNoteActions(
BuildContext context,
Note note,
NoteRepository repository,
) async {
await showModalBottomSheet<void>(
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);
},
),
);
}

View File

@ -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<NotesListScreen> {
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<NotesListScreen> {
}
}
/// Opens the full note: read it, edit the body, change priority/status.
Future<void> _openNote(Note note) async {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) =>
NoteDetailScreen(note: note, repository: widget.repository),
),
);
}
/// Opens the per-note quick-actions sheet (priority, status, delete).
Future<void> _openNoteActions(Note note) async {
await showModalBottomSheet<void>(
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<NotesListScreen> {
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<NotesListScreen> {
}
/// 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 = <String>[
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<void> Function(Note) onChanged;
final Future<void> 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<void> _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<Status>(
label: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
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<T> extends StatelessWidget {
const _EnumChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onSelected,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> 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<Priority> _priorities = {...widget.initial.priorities};
late Set<Status> _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<T>(Set<T> 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<Status>(
label: 'Status',
values: Status.values,
selected: _statuses,
labelOf: (s) => s.label,
onToggle: (s) => _toggle(_statuses, s),
),
const SizedBox(height: 12),
_MultiChips<Priority>(
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<T> extends StatelessWidget {
const _MultiChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onToggle,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> 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<void> _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');
}

View File

@ -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

31
scripts/check_file_length.sh Executable file
View File

@ -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 <file> [<file> ...] # 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 "$@"

View File

@ -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 "$@"