mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-12 08:30:59 +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
|
/// 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
|
/// 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
|
/// verbatim to an LLM, so the description of what each section means must
|
||||||
/// travel with it.
|
/// travel with it.
|
||||||
///
|
///
|
||||||
/// Design invariants (see the round-trip tests):
|
/// The conversion itself ([assemble], [parse], [noteTitle], [isBareLink]) lives
|
||||||
/// * The **template** defines which sections exist; the stored text only
|
/// in `note_template_parser.dart` and is re-exported below, so importing this
|
||||||
/// carries values. Parsing an unknown/legacy note never invents sections.
|
/// file still gets the whole template API in one import.
|
||||||
/// * `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;
|
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.
|
/// One field of a structured template.
|
||||||
class TemplateSection {
|
class TemplateSection {
|
||||||
/// Creates a [TemplateSection] from its field/label/helper text.
|
/// Creates a [TemplateSection] from its field/label/helper text.
|
||||||
@ -201,170 +201,3 @@ class NoteTemplate {
|
|||||||
static NoteTemplate forDraft(String text) =>
|
static NoteTemplate forDraft(String text) =>
|
||||||
isBareLink(text) ? blank : defaultTemplate;
|
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;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
/// NUL separates the arguments inside `/proc/<pid>/cmdline`.
|
// The real-host probe lives in its own file (size), but PortGuard defaults to
|
||||||
final String _argumentSeparator = String.fromCharCode(0);
|
// 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.
|
/// What the launcher should do when the fixed wrapper port is already bound.
|
||||||
///
|
///
|
||||||
@ -227,89 +230,3 @@ class PortGuard {
|
|||||||
return !await probe.portInUse(port);
|
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.
|
/// 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
|
/// * `firebase_project.dart` — [kProject], the public project identifiers.
|
||||||
/// identifiers that already ship inside the APK; the security rules, not
|
/// * `firebase_account_store.dart` — the account and refresh token, in the OS
|
||||||
/// their secrecy, are what protect the data.
|
/// keystore, plus the desktop wrapper's self-provisioning fallback.
|
||||||
/// * The account email and password are entered once per device and kept in
|
/// * `firebase_client.dart` — opening a signed-in client, and reporting
|
||||||
/// the OS keystore, next to the GitHub token this app already stores there.
|
/// 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
|
/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python
|
||||||
/// half. On Android there is no such file.
|
/// half. On Android there is no such file.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:convert';
|
export 'firebase_account_store.dart';
|
||||||
import 'dart:developer';
|
export 'firebase_client.dart';
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
export 'firebase_project.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
|
|
||||||
|
|||||||
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.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/ui/note_tile.dart';
|
||||||
import 'package:todo/ui/confirm.dart';
|
import 'package:todo/ui/notes_filter_sheet.dart';
|
||||||
import 'package:todo/ui/note_detail_screen.dart';
|
import 'package:todo/ui/notes_list_navigation.dart';
|
||||||
|
|
||||||
/// The default status selection: hide completed/dropped work. This is the
|
/// The default status selection: hide completed/dropped work. This is the
|
||||||
/// app's notion of "unfiltered", so it does not count towards the filter
|
/// app's notion of "unfiltered", so it does not count towards the filter
|
||||||
@ -103,7 +103,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
builder: (_) => _FilterSheet(initial: _filter),
|
builder: (_) => FilterSheet(initial: _filter),
|
||||||
);
|
);
|
||||||
if (edited != null) {
|
if (edited != null) {
|
||||||
_filter = edited.copyWith(query: _searchController.text);
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final badgeCount = _badgeCount;
|
final badgeCount = _badgeCount;
|
||||||
@ -236,11 +204,13 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
itemCount: notes.length,
|
itemCount: notes.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, i) => _NoteTile(
|
itemBuilder: (context, i) => NoteTile(
|
||||||
key: ValueKey(notes[i].id),
|
key: ValueKey(notes[i].id),
|
||||||
note: notes[i],
|
note: notes[i],
|
||||||
onTap: () => _openNote(notes[i]),
|
onTap: () =>
|
||||||
onActions: () => _openNoteActions(notes[i]),
|
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.
|
/// 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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.12.2
|
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.
|
# Install this repo's git hooks.
|
||||||
#
|
#
|
||||||
# .git/hooks/ is not tracked, so a fresh clone has no hooks. Run this once
|
# .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
|
# after cloning.
|
||||||
# pre-commit framework (its quality gate is CI), so the hook is a plain script.
|
#
|
||||||
|
# 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
|
set -euo pipefail
|
||||||
@ -13,27 +19,36 @@ set -euo pipefail
|
|||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
readonly REPO_ROOT
|
readonly REPO_ROOT
|
||||||
readonly HOOK="$REPO_ROOT/.git/hooks/pre-commit"
|
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() {
|
main() {
|
||||||
if [[ -e "$HOOK" ]] && ! grep -q 'bump_patch_version.sh' "$HOOK"; then
|
ensure_pre_commit
|
||||||
echo "Error: $HOOK exists and is not ours; refusing to clobber it." >&2
|
remove_legacy_hook
|
||||||
exit 1
|
pre-commit install
|
||||||
fi
|
echo "Installed pre-commit hooks: $HOOK"
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user