mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
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:
parent
2c97a9c19c
commit
9d2aa1c178
33
.github/workflows/file-length.yml
vendored
Normal file
33
.github/workflows/file-length.yml
vendored
Normal 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
43
.pre-commit-config.yaml
Normal 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
|
||||
@ -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');
|
||||
}
|
||||
|
||||
186
lib/data/note_template_parser.dart
Normal file
186
lib/data/note_template_parser.dart
Normal 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');
|
||||
}
|
||||
100
lib/desktop/linux_system_probe.dart
Normal file
100
lib/desktop/linux_system_probe.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
lib/sync/firebase_account_store.dart
Normal file
156
lib/sync/firebase_account_store.dart
Normal 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
|
||||
@ -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';
|
||||
|
||||
162
lib/sync/firebase_client.dart
Normal file
162
lib/sync/firebase_client.dart
Normal 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
|
||||
24
lib/sync/firebase_project.dart
Normal file
24
lib/sync/firebase_project.dart
Normal 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',
|
||||
);
|
||||
152
lib/ui/note_actions_sheet.dart
Normal file
152
lib/ui/note_actions_sheet.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/ui/note_date_range_field.dart
Normal file
109
lib/ui/note_date_range_field.dart
Normal 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
69
lib/ui/note_tile.dart
Normal 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.
|
||||
184
lib/ui/notes_filter_sheet.dart
Normal file
184
lib/ui/notes_filter_sheet.dart
Normal 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.
|
||||
52
lib/ui/notes_list_navigation.dart
Normal file
52
lib/ui/notes_list_navigation.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
31
scripts/check_file_length.sh
Executable 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 "$@"
|
||||
@ -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 "$@"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user