mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Four optional note fields (repo, done_check, by, evidence) in the crdt_sync record, written only when set, so editing a plain idea stays wire-identical to v3 (note_repository_sync_compat_test). The log is schemaless: no stored schema version; the legacy sqlite reader stays at v3. NoteFilter gains a sources facet (ideas / every repo task / one repo); search also matches the repo. The notes list shows only open ideas by default (no badge); the filter sheet adds Ideas, Repo tasks and one chip per repo when repo tasks exist. The per-note sheet shows the task fields read-only. BACKLOG.md markers carry the new attributes, entity-escaped. CLI: --repo --done-check --by on add, --close <id> --evidence (refused without), --list [--repo] [--status], --search, and every id flag takes a unique 8-character prefix. --list prints id8, status, priority, repo, title. install_hooks.sh: the wrapper finds pre-commit's generated hook next to itself, so commits from a git worktree (where .git is a file) work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
6.2 KiB
Dart
195 lines
6.2 KiB
Dart
/// How notes are selected and ordered: the sort modes, the filter, and the
|
|
/// pure predicates behind them.
|
|
///
|
|
/// Split out of `note_repository.dart` for file size; that file re-exports
|
|
/// this one, so callers import it as before. Everything here is pure -- it
|
|
/// takes notes and a filter and returns a decision -- which is what made it
|
|
/// the natural seam.
|
|
library;
|
|
|
|
import 'package:todo/data/note.dart';
|
|
|
|
/// [NoteFilter.sources] entry for plain ideas (notes without a repo).
|
|
const kIdeasSource = '';
|
|
|
|
/// [NoteFilter.sources] entry admitting every repo task.
|
|
const kRepoTasksSource = '*';
|
|
|
|
/// How the history list should be ordered.
|
|
enum NoteSort {
|
|
/// Newest created first.
|
|
createdDesc,
|
|
|
|
/// Most recently modified first.
|
|
modifiedDesc,
|
|
|
|
/// A-Z by note text.
|
|
alphabetical,
|
|
|
|
/// Highest [Priority] first.
|
|
priorityDesc,
|
|
}
|
|
|
|
/// An immutable set of constraints for querying notes.
|
|
///
|
|
/// All fields combine with logical AND. Empty/null fields impose no
|
|
/// constraint. Lives in the data layer so the filtering it drives never leaks
|
|
/// into the UI. Construct copies with [copyWith] when toggling one facet.
|
|
class NoteFilter {
|
|
/// Creates a [NoteFilter] from its constraint fields.
|
|
const NoteFilter({
|
|
this.query = '',
|
|
this.priorities = const {},
|
|
this.statuses = const {},
|
|
this.sources = const {},
|
|
this.createdFrom,
|
|
this.createdTo,
|
|
this.updatedFrom,
|
|
this.updatedTo,
|
|
});
|
|
|
|
/// Case-insensitive substring matched against the note body.
|
|
final String query;
|
|
|
|
/// Notes must have one of these priorities. Empty means "any priority".
|
|
final Set<Priority> priorities;
|
|
|
|
/// Notes must have one of these statuses. Empty means "any status".
|
|
final Set<Status> statuses;
|
|
|
|
/// Which notes may show: [kIdeasSource], [kRepoTasksSource] and/or repo
|
|
/// names. Empty means every note.
|
|
final Set<String> sources;
|
|
|
|
/// Inclusive lower bound (by calendar day) on the creation date.
|
|
final DateTime? createdFrom;
|
|
|
|
/// Inclusive upper bound (by calendar day) on the creation date.
|
|
final DateTime? createdTo;
|
|
|
|
/// Inclusive lower bound (by calendar day) on the last-updated date.
|
|
final DateTime? updatedFrom;
|
|
|
|
/// Inclusive upper bound (by calendar day) on the last-updated date.
|
|
final DateTime? updatedTo;
|
|
|
|
/// True when no constraint is active (the unfiltered, full list).
|
|
bool get isEmpty =>
|
|
query.trim().isEmpty &&
|
|
priorities.isEmpty &&
|
|
statuses.isEmpty &&
|
|
sources.isEmpty &&
|
|
createdFrom == null &&
|
|
createdTo == null &&
|
|
updatedFrom == null &&
|
|
updatedTo == null;
|
|
|
|
/// Number of distinct active facets, for an "N filters" badge in the UI.
|
|
int get activeCount {
|
|
var n = 0;
|
|
if (query.trim().isNotEmpty) n++;
|
|
if (priorities.isNotEmpty) n++;
|
|
if (statuses.isNotEmpty) n++;
|
|
if (sources.isNotEmpty) n++;
|
|
if (createdFrom != null || createdTo != null) n++;
|
|
if (updatedFrom != null || updatedTo != null) n++;
|
|
return n;
|
|
}
|
|
|
|
/// Returns a copy with selected facets replaced. A `null` argument keeps
|
|
/// the current value; clearing a date is done via the dedicated clear
|
|
/// flags so `null` can mean "unchanged".
|
|
NoteFilter copyWith({
|
|
String? query,
|
|
Set<Priority>? priorities,
|
|
Set<Status>? statuses,
|
|
Set<String>? sources,
|
|
DateTime? createdFrom,
|
|
DateTime? createdTo,
|
|
DateTime? updatedFrom,
|
|
DateTime? updatedTo,
|
|
bool clearCreated = false,
|
|
bool clearUpdated = false,
|
|
}) {
|
|
return NoteFilter(
|
|
query: query ?? this.query,
|
|
priorities: priorities ?? this.priorities,
|
|
statuses: statuses ?? this.statuses,
|
|
sources: sources ?? this.sources,
|
|
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
|
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
|
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
|
updatedTo: clearUpdated ? null : (updatedTo ?? this.updatedTo),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Whether [note] passes every clause of [filter].
|
|
///
|
|
/// Clauses are AND-combined, and an empty clause matches everything -- an
|
|
/// empty priority set means "any priority", not "no priorities".
|
|
bool matchesFilter(Note note, NoteFilter filter) {
|
|
final query = filter.query.trim().toLowerCase();
|
|
if (query.isNotEmpty &&
|
|
!note.text.toLowerCase().contains(query) &&
|
|
!(note.repo?.toLowerCase().contains(query) ?? false)) {
|
|
return false;
|
|
}
|
|
if (filter.sources.isNotEmpty && !_sourceAllowed(note, filter.sources)) {
|
|
return false;
|
|
}
|
|
if (filter.priorities.isNotEmpty &&
|
|
!filter.priorities.contains(note.priority)) {
|
|
return false;
|
|
}
|
|
if (filter.statuses.isNotEmpty && !filter.statuses.contains(note.status)) {
|
|
return false;
|
|
}
|
|
if (!_withinDays(note.createdAt, filter.createdFrom, filter.createdTo)) {
|
|
return false;
|
|
}
|
|
if (!_withinDays(note.updatedAt, filter.updatedFrom, filter.updatedTo)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool _sourceAllowed(Note note, Set<String> sources) {
|
|
final repo = note.repo;
|
|
if (repo == null) return sources.contains(kIdeasSource);
|
|
return sources.contains(kRepoTasksSource) || sources.contains(repo);
|
|
}
|
|
|
|
/// Inclusive day-granularity range check, matching the old SQL bounds:
|
|
/// `from` includes its whole day; `to` includes its whole day.
|
|
bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
|
|
if (from != null && value.isBefore(_startOfDay(from))) return false;
|
|
if (to != null &&
|
|
!value.isBefore(_startOfDay(to).add(const Duration(days: 1)))) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// The comparator implementing [sort].
|
|
Comparator<Note> comparatorFor(NoteSort sort) {
|
|
switch (sort) {
|
|
case NoteSort.createdDesc:
|
|
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
|
case NoteSort.modifiedDesc:
|
|
return (a, b) => b.updatedAt.compareTo(a.updatedAt);
|
|
case NoteSort.alphabetical:
|
|
return (a, b) => a.text.toLowerCase().compareTo(b.text.toLowerCase());
|
|
case NoteSort.priorityDesc:
|
|
return (a, b) {
|
|
final byPriority = b.priority.value.compareTo(a.priority.value);
|
|
return byPriority != 0
|
|
? byPriority
|
|
: b.updatedAt.compareTo(a.updatedAt);
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Midnight (local) of [t]'s calendar day.
|
|
DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day);
|