One list: repo groups, repo search, compact filter
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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 13:39:55 +01:00
parent 9b42ff8ba2
commit 67212cd7ec
24 changed files with 755 additions and 266 deletions

View File

@ -66,7 +66,7 @@ Three layers under `lib/`, each single-purpose:
`by`, `evidence`, written only when set, so an idea's record stays
v3-identical: `note_repository_sync_compat_test.dart`). The old sqlite
schema (v3) survives only in `legacy_sqlite_migration.dart`. Also defines
`NoteFilter` (query / priorities / statuses / sources / date ranges,
`NoteFilter` (query / priorities / statuses / repo / date ranges,
AND-combined), `NoteSort`, `watchNotes`/`watchCount` streams, and
`importNotes` (safe newer-wins merge by id).
- `sync/` — GitHub-as-storage sync.
@ -85,7 +85,7 @@ Three layers under `lib/`, each single-purpose:
- `capture_screen.dart`: landing screen; always-focused text box pre-filled
with the structured template; lazy note creation on first keystroke.
- `notes_list_screen.dart`: list + search + sort + filter sheet + per-note
sheet. **Default view hides Done/Abandoned and shows no filter badge**
sheet. Notes sharing a `repo` collapse into one row (`repo_group.dart`). **Default view hides Done/Abandoned and shows no filter badge**
(looks unfiltered). Under 600dp wide (`kNotesGridMaxWidth`) the notes are
a 2-column `NotesGrid` of compact `NoteGridTile`s (title + priority/status
cues; body and dates are in the long-press sheet). The tile height is

View File

@ -45,7 +45,7 @@ class CliCommands {
filter: NoteFilter(
query: args.opts.query ?? '',
statuses: {?args.status},
sources: {?args.opts.repo},
repo: args.opts.repo,
),
);
notes.map(listLine).forEach(_out.writeln);

View File

@ -149,6 +149,9 @@ class Note {
/// Whether this note is a repo task rather than a plain idea.
bool get isRepoTask => repo != null;
/// The repo without its owner ("kartka" for "kuhyx/kartka"), or null.
String? get repoName => repo?.split('/').last;
/// Returns a copy with selected fields replaced.
Note copyWith({
String? text,

View File

@ -9,12 +9,6 @@ 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.
@ -41,7 +35,8 @@ class NoteFilter {
this.query = '',
this.priorities = const {},
this.statuses = const {},
this.sources = const {},
this.repo,
this.repoTasksOnly = false,
this.createdFrom,
this.createdTo,
this.updatedFrom,
@ -57,9 +52,11 @@ class NoteFilter {
/// 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;
/// Only notes whose repo equals this. Null means any repo (or none).
final String? repo;
/// Only notes that have a repo (repo tasks). Ideas are excluded.
final bool repoTasksOnly;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
@ -78,7 +75,8 @@ class NoteFilter {
query.trim().isEmpty &&
priorities.isEmpty &&
statuses.isEmpty &&
sources.isEmpty &&
repo == null &&
!repoTasksOnly &&
createdFrom == null &&
createdTo == null &&
updatedFrom == null &&
@ -90,7 +88,8 @@ class NoteFilter {
if (query.trim().isNotEmpty) n++;
if (priorities.isNotEmpty) n++;
if (statuses.isNotEmpty) n++;
if (sources.isNotEmpty) n++;
if (repo != null) n++;
if (repoTasksOnly) n++;
if (createdFrom != null || createdTo != null) n++;
if (updatedFrom != null || updatedTo != null) n++;
return n;
@ -103,7 +102,8 @@ class NoteFilter {
String? query,
Set<Priority>? priorities,
Set<Status>? statuses,
Set<String>? sources,
String? repo,
bool? repoTasksOnly,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
@ -115,7 +115,8 @@ class NoteFilter {
query: query ?? this.query,
priorities: priorities ?? this.priorities,
statuses: statuses ?? this.statuses,
sources: sources ?? this.sources,
repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
@ -135,9 +136,8 @@ bool matchesFilter(Note note, NoteFilter filter) {
!(note.repo?.toLowerCase().contains(query) ?? false)) {
return false;
}
if (filter.sources.isNotEmpty && !_sourceAllowed(note, filter.sources)) {
return false;
}
if (filter.repo != null && note.repo != filter.repo) return false;
if (filter.repoTasksOnly && note.repo == null) return false;
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
@ -154,12 +154,6 @@ bool matchesFilter(Note note, NoteFilter filter) {
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) {

View File

@ -72,10 +72,7 @@ class TaskService {
await _pull();
return _lines(
await _session.repository.listNotes(
filter: NoteFilter(
statuses: statuses,
sources: {repo ?? kRepoTasksSource},
),
filter: NoteFilter(statuses: statuses, repo: repo, repoTasksOnly: true),
),
);
});
@ -86,7 +83,7 @@ class TaskService {
await _pull();
return _lines(
await _session.repository.listNotes(
filter: NoteFilter(query: query, sources: {kRepoTasksSource}),
filter: NoteFilter(query: query, repoTasksOnly: true),
),
);
});

View File

@ -5,9 +5,11 @@
/// different file.
library;
import 'dart:async';
import 'package:flutter/material.dart';
/// A labelled date range shown as presets plus a custom-range picker.
/// A labelled date range shown as one preset menu plus a custom picker.
class DateRangeField extends StatelessWidget {
/// Creates a labelled date-range control.
const DateRangeField({
@ -52,49 +54,60 @@ class DateRangeField extends StatelessWidget {
if (picked != null) onChanged(picked.start, picked.end);
}
/// Which menu entry describes the current range.
_Preset get _current {
if (!_hasRange) return _Preset.any;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
for (final p in _Preset.dayPresets) {
final days = p.days!;
if (to == today && from == today.subtract(Duration(days: days - 1))) {
return p;
}
}
return _Preset.custom;
}
void _select(BuildContext context, _Preset preset) {
switch (preset) {
case _Preset.any:
onChanged(null, null);
case _Preset.custom:
unawaited(_pickCustom(context));
case _Preset.today || _Preset.week || _Preset.month:
_applyDays(preset.days!);
}
}
@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: 8),
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),
),
],
),
final current = _current;
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
// custom range is shown must still reopen the picker.
return PopupMenuButton<_Preset>(
tooltip: label,
onSelected: (p) => _select(context, p),
itemBuilder: (_) => [
for (final p in _Preset.values)
PopupMenuItem(value: p, child: Text(p.label)),
],
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
isDense: true,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
child: Text(
current == _Preset.custom ? _rangeLabel() : current.label,
style: theme.textTheme.bodyMedium,
),
),
);
}
@ -107,3 +120,19 @@ class DateRangeField extends StatelessWidget {
String _two(int n) => n.toString().padLeft(2, '0');
}
/// Entries of the range menu, in display order.
enum _Preset {
any('Any', null),
today('Today', 1),
week('7 days', 7),
month('30 days', 30),
custom('Custom…', null);
const _Preset(this.label, this.days);
final String label;
final int? days;
static const List<_Preset> dayPresets = [today, week, month];
}

View File

@ -82,6 +82,13 @@ class NoteGridTile extends StatelessWidget {
),
),
),
if (note.repoName case final repo?)
Text(
repo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: cue?.copyWith(color: scheme.onSurfaceVariant),
),
Row(
children: [
Icon(

View File

@ -16,6 +16,7 @@ class NoteTile extends StatelessWidget {
required this.note,
required this.onTap,
required this.onActions,
this.showRepo = true,
super.key,
});
@ -30,6 +31,9 @@ class NoteTile extends StatelessWidget {
/// Called when the trailing actions button is tapped.
final VoidCallback onActions;
/// Whether the repo name joins the metadata line (off on its own screen).
final bool showRepo;
@override
Widget build(BuildContext context) {
final firstLine = noteTitle(note.text);
@ -38,6 +42,7 @@ class NoteTile extends StatelessWidget {
note.status.label,
note.priority.label,
'edited ${_relative(note.updatedAt)}',
if (showRepo) ?note.repoName,
].join(' · ');
return ListTile(
title: Text(

View File

@ -14,31 +14,22 @@ import 'package:todo/ui/note_date_range_field.dart';
import 'package:todo/ui/notes_list_defaults.dart';
/// The filter editing sheet: priority + status multi-select and Created /
/// Last-updated date ranges (presets + a custom range picker). Edits a
/// Last-updated date ranges (one compact preset menu + custom 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, this.repos = const [], super.key});
const FilterSheet({required this.initial, super.key});
/// The filter the sheet opens with; edits are made to a working copy.
final NoteFilter initial;
/// Distinct repos of the stored repo tasks, one chip each.
final List<String> repos;
@override
State<FilterSheet> createState() => _FilterSheetState();
}
const Map<String, String> _sourceLabels = {
kIdeasSource: 'Ideas',
kRepoTasksSource: 'Repo tasks',
};
class _FilterSheetState extends State<FilterSheet> {
late Set<Priority> _priorities = {...widget.initial.priorities};
late Set<Status> _statuses = {...widget.initial.statuses};
late Set<String> _sources = {...widget.initial.sources};
late DateTime? _createdFrom = widget.initial.createdFrom;
late DateTime? _createdTo = widget.initial.createdTo;
late DateTime? _updatedFrom = widget.initial.updatedFrom;
@ -54,7 +45,6 @@ class _FilterSheetState extends State<FilterSheet> {
// Reset to the default view (hide Done/Abandoned), not an empty set,
// so "Clear all" matches the app's unfiltered baseline.
_statuses = {...kDefaultStatuses};
_sources = {...kDefaultSources};
_createdFrom = null;
_createdTo = null;
_updatedFrom = null;
@ -67,7 +57,6 @@ class _FilterSheetState extends State<FilterSheet> {
return NoteFilter(
priorities: _priorities,
statuses: _statuses,
sources: _sources,
createdFrom: _createdFrom,
createdTo: _createdTo,
updatedFrom: _updatedFrom,
@ -102,16 +91,6 @@ class _FilterSheetState extends State<FilterSheet> {
],
),
const SizedBox(height: 8),
if (widget.repos.isNotEmpty) ...[
_MultiChips<String>(
label: 'Show',
values: [kIdeasSource, kRepoTasksSource, ...widget.repos],
selected: _sources,
labelOf: (s) => _sourceLabels[s] ?? s,
onToggle: (s) => _toggle(_sources, s),
),
const SizedBox(height: 12),
],
_MultiChips<Status>(
label: 'Status',
values: Status.values,

View File

@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
import 'package:todo/ui/repo_group.dart';
import 'package:todo/ui/repo_tiles.dart';
/// Below this width the notes list is a grid; at or above it, the list.
const double kNotesGridMaxWidth = 600;
@ -23,14 +25,15 @@ double _tileHeightFor(double height) {
class NotesGrid extends StatelessWidget {
/// Creates the grid; [onOpen] and [onActions] get the tapped note.
const NotesGrid({
required this.notes,
required this.entries,
required this.onOpen,
required this.onActions,
required this.onOpenRepo,
super.key,
});
/// Notes in display order.
final List<Note> notes;
/// Rows in display order: single notes and repo groups.
final List<ListEntry> entries;
/// Tap on a tile.
final ValueChanged<Note> onOpen;
@ -38,14 +41,22 @@ class NotesGrid extends StatelessWidget {
/// Long-press on a tile.
final ValueChanged<Note> onActions;
Widget _tile(Note note) {
return NoteGridTile(
/// Tap on a repo group tile.
final ValueChanged<RepoEntry> onOpenRepo;
Widget _tile(ListEntry entry) => switch (entry) {
NoteEntry(:final note) => NoteGridTile(
key: ValueKey(note.id),
note: note,
onTap: () => onOpen(note),
onActions: () => onActions(note),
);
}
),
final RepoEntry repo => RepoGridTile(
key: ValueKey(repo.repo),
entry: repo,
onTap: () => onOpenRepo(repo),
),
};
SliverGridDelegate _delegate(double viewportHeight) {
return SliverGridDelegateWithFixedCrossAxisCount(
@ -66,8 +77,8 @@ class NotesGrid extends StatelessWidget {
builder: (context, box) => GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: _sidePadding),
gridDelegate: _delegate(box.maxHeight),
itemCount: notes.length,
itemBuilder: (context, i) => _tile(notes[i]),
itemCount: entries.length,
itemBuilder: (context, i) => _tile(entries[i]),
),
),
),

View File

@ -0,0 +1,66 @@
/// The notes list's body: phone grid or desktop list of grouped rows.
///
/// Split out of `notes_list_screen.dart` for file size.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_tile.dart';
import 'package:todo/ui/notes_grid.dart';
import 'package:todo/ui/repo_group.dart';
import 'package:todo/ui/repo_tiles.dart';
/// Lays [entries] out as a grid below [kNotesGridMaxWidth], else a list.
class NotesListBody extends StatelessWidget {
/// Creates the body; callbacks get the tapped note or repo group.
const NotesListBody({
required this.entries,
required this.onOpen,
required this.onActions,
required this.onOpenRepo,
super.key,
});
/// Rows in display order.
final List<ListEntry> entries;
/// Tap on a note.
final ValueChanged<Note> onOpen;
/// Long-press (or the actions button) on a note.
final ValueChanged<Note> onActions;
/// Tap on a repo group.
final ValueChanged<RepoEntry> onOpenRepo;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(
entries: entries,
onOpen: onOpen,
onActions: onActions,
onOpenRepo: onOpenRepo,
)
: ListView.separated(
itemCount: entries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => switch (entries[i]) {
NoteEntry(:final note) => NoteTile(
key: ValueKey(note.id),
note: note,
onTap: () => onOpen(note),
onActions: () => onActions(note),
),
final RepoEntry repo => RepoListTile(
key: ValueKey(repo.repo),
entry: repo,
onTap: () => onOpenRepo(repo),
),
},
),
);
}
}

View File

@ -4,20 +4,13 @@ import 'package:todo/data/note_repository.dart';
/// The default status selection: hide completed/dropped work.
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
/// The default source selection: plain ideas only, repo tasks hidden.
const Set<String> kDefaultSources = {kIdeasSource};
/// The notes list's unfiltered baseline, which "Clear all" resets to.
const kDefaultNoteFilter = NoteFilter(
statuses: kDefaultStatuses,
sources: kDefaultSources,
);
const kDefaultNoteFilter = NoteFilter(statuses: kDefaultStatuses);
/// Facets the user changed from [kDefaultNoteFilter], for the filter badge.
int userFacetCount(NoteFilter filter) {
var count = filter.activeCount;
if (_same(filter.statuses, kDefaultStatuses)) count -= 1;
if (_same(filter.sources, kDefaultSources)) count -= 1;
return count;
}

View File

@ -5,11 +5,13 @@ import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/note_tile.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/notes_filter_sheet.dart';
import 'package:todo/ui/notes_grid.dart';
import 'package:todo/ui/notes_list_body.dart';
import 'package:todo/ui/notes_list_defaults.dart';
import 'package:todo/ui/notes_list_navigation.dart';
import 'package:todo/ui/repo_group.dart';
import 'package:todo/ui/repo_screen.dart';
export 'package:todo/ui/notes_list_defaults.dart';
@ -48,7 +50,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
NoteSort _sort = NoteSort.priorityDesc;
/// Default view: open ideas only (no Done/Abandoned, no repo tasks).
/// Default view: open notes (no Done/Abandoned), ideas and repo tasks alike.
NoteFilter _filter = kDefaultNoteFilter;
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
@ -92,15 +94,11 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Opens the filter sheet and adopts the edited filter (text query is
/// owned by the search box, so it is preserved across the round-trip).
Future<void> _openFilters() async {
final repos = {
for (final n in await widget.repository.listNotes()) ?n.repo,
}.toList()..sort();
if (!mounted) return;
final edited = await showModalBottomSheet<NoteFilter>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => FilterSheet(initial: _filter, repos: repos),
builder: (_) => FilterSheet(initial: _filter),
);
if (edited != null) {
_filter = edited.copyWith(query: _searchController.text);
@ -108,6 +106,22 @@ class _NotesListScreenState extends State<NotesListScreen> {
}
}
Future<void> _openRepo(String repo) async {
FocusManager.instance.primaryFocus?.unfocus();
await Navigator.of(context).push<void>(
appRoute(
context,
(_) => RepoScreen(
repo: repo,
filter: _filter,
sort: _sort,
repository: widget.repository,
appSettings: widget.appSettings,
),
),
);
}
@override
Widget build(BuildContext context) {
final badgeCount = userFacetCount(_filter);
@ -202,23 +216,12 @@ class _NotesListScreenState extends State<NotesListScreen> {
openNote(context, n, widget.repository, widget.appSettings);
void actions(Note n) =>
openNoteActions(context, n, widget.repository);
return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(
notes: notes,
onOpen: open,
onActions: actions,
)
: ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => NoteTile(
key: ValueKey(notes[i].id),
note: notes[i],
onTap: () => open(notes[i]),
onActions: () => actions(notes[i]),
),
),
void openRepo(RepoEntry r) => _openRepo(r.repo);
return NotesListBody(
entries: groupByRepo(notes),
onOpen: open,
onActions: actions,
onOpenRepo: openRepo,
);
},
),

70
lib/ui/repo_group.dart Normal file
View File

@ -0,0 +1,70 @@
/// Display-only grouping of the notes list: every note sharing a `repo`
/// collapses into one [RepoEntry]; notes without a repo stay [NoteEntry]s.
///
/// Storage is untouched -- each note keeps its own done_check and closes
/// individually. Grouping runs on the already filtered/sorted list, so a
/// group only counts notes that passed the active filters.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
/// One row of the notes list.
sealed class ListEntry {
const ListEntry();
}
/// A plain note shown as its own row.
final class NoteEntry extends ListEntry {
/// Wraps [note].
const NoteEntry(this.note);
/// The note this row stands for.
final Note note;
}
/// All notes of one repo, shown as a single row.
final class RepoEntry extends ListEntry {
/// Groups [notes], which all carry [repo].
const RepoEntry(this.repo, this.notes);
/// The full `owner/name` repo.
final String repo;
/// Group members in list order.
final List<Note> notes;
/// Repo name without its owner.
String get name => repo.split('/').last;
}
/// Collapses [notes] (already filtered and sorted) into rows. A repo's row
/// takes the position of its first note.
List<ListEntry> groupByRepo(List<Note> notes) {
final groups = <String, List<Note>>{};
final entries = <ListEntry>[];
for (final n in notes) {
final repo = n.repo;
if (repo == null) {
entries.add(NoteEntry(n));
} else if (groups[repo] case final members?) {
members.add(n);
} else {
final members = groups[repo] = [n];
entries.add(RepoEntry(repo, members));
}
}
return entries;
}
/// One plain-text block for [notes]: title, body and `id: <id8>` per task,
/// tasks separated by a blank line.
String repoClipboardText(List<Note> notes) {
return notes.map(_block).join('\n\n');
}
String _block(Note n) {
final body = n.text.split('\n').skip(1).join('\n').trim();
final id = n.id.length <= 8 ? n.id : n.id.substring(0, 8);
return [noteTitle(n.text), if (body.isNotEmpty) body, 'id: $id'].join('\n');
}

99
lib/ui/repo_screen.dart Normal file
View File

@ -0,0 +1,99 @@
/// A repo's tasks on one screen, with a copy-all action.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/note_tile.dart';
import 'package:todo/ui/notes_list_navigation.dart';
import 'package:todo/ui/repo_group.dart';
/// Lists the tasks of [repo] that pass the list's active [filter].
///
/// Display only: each task is still its own note, opened with the usual
/// detail screen. The copy action puts every listed task (title, body and
/// `id: <id8>`) on the clipboard as one plain-text block.
class RepoScreen extends StatefulWidget {
/// Creates the screen for [repo], narrowed by the list's [filter]/[sort].
const RepoScreen({
required this.repo,
required this.filter,
required this.sort,
required this.repository,
required this.appSettings,
super.key,
});
/// The full `owner/name` repo.
final String repo;
/// The notes list's active filter; the repo is added on top.
final NoteFilter filter;
/// The notes list's sort order.
final NoteSort sort;
/// The store the tasks live in.
final NoteRepository repository;
/// Passed on to the note detail screen.
final ValueNotifier<AppSettings> appSettings;
@override
State<RepoScreen> createState() => _RepoScreenState();
}
class _RepoScreenState extends State<RepoScreen> {
late final Stream<List<Note>> _stream = widget.repository.watchNotes(
sort: widget.sort,
filter: widget.filter.copyWith(repo: widget.repo),
);
List<Note> _notes = const [];
bool _copied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: repoClipboardText(_notes)));
await HapticFeedback.lightImpact();
if (mounted) setState(() => _copied = true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.repo.split('/').last),
actions: [
IconButton(
tooltip: 'Copy all',
icon: Icon(_copied ? Icons.check : Icons.copy_all),
onPressed: _copy,
),
],
),
body: StreamBuilder<List<Note>>(
stream: _stream,
builder: (context, snapshot) {
_notes = snapshot.data ?? const <Note>[];
return ListView.separated(
itemCount: _notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final n = _notes[i];
return NoteTile(
key: ValueKey(n.id),
note: n,
showRepo: false,
onTap: () =>
openNote(context, n, widget.repository, widget.appSettings),
onActions: () => openNoteActions(context, n, widget.repository),
);
},
);
},
),
);
}
}

73
lib/ui/repo_tiles.dart Normal file
View File

@ -0,0 +1,73 @@
/// The single row a repo's notes collapse into, as a grid tile (phone) and a
/// list tile (desktop).
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/ui/repo_group.dart';
String _label(RepoEntry entry) => '${entry.name} · ${entry.notes.length}';
/// Grid cell for a repo group.
class RepoGridTile extends StatelessWidget {
/// Creates a tile for [entry]; [onTap] opens the repo screen.
const RepoGridTile({required this.entry, required this.onTap, super.key});
/// The group this tile stands for.
final RepoEntry entry;
/// Open the repo screen.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Row(
children: [
Icon(Icons.folder_outlined, color: scheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
_label(entry),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
),
);
}
}
/// List row for a repo group.
class RepoListTile extends StatelessWidget {
/// Creates a row for [entry]; [onTap] opens the repo screen.
const RepoListTile({required this.entry, required this.onTap, super.key});
/// The group this row stands for.
final RepoEntry entry;
/// Open the repo screen.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(_label(entry)),
onTap: onTap,
);
}
}

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.55+1
version: 1.0.56+1
environment:
sdk: ^3.12.2

View File

@ -15,7 +15,12 @@ import 'package:todo/data/note_repository.dart';
/// so list-screen tests can assert the UI built the right query without
/// re-testing the repository's SQL (covered separately by unit tests).
class FakeNoteRepository implements NoteRepository {
FakeNoteRepository([List<Note>? initial]) : _notes = [...?initial];
FakeNoteRepository([List<Note>? initial, this.applyFilter = false])
: _notes = [...?initial];
/// When true, [watchNotes] applies the real filter + sort like the SQL
/// repository; off by default so older tests keep seeing every note.
final bool applyFilter;
final List<Note> _notes;
final _controller = StreamController<List<Note>>.broadcast();
@ -87,7 +92,13 @@ class FakeNoteRepository implements NoteRepository {
}) {
lastSort = sort;
lastFilter = filter;
return _snapshots();
if (!applyFilter) return _snapshots();
return _snapshots().map(
(all) => [
for (final n in all)
if (matchesFilter(n, filter)) n,
]..sort(comparatorFor(sort)),
);
}
@override

View File

@ -46,45 +46,43 @@ void main() {
});
});
group('NoteFilter.sources', () {
group('NoteFilter repo facets', () {
final idea = _note('idea');
final todo = _note('t', repo: 'kuhyx/todo');
final utils = _note('u', repo: 'kuhyx/utils');
List<String> pass(Set<String> sources) => [
List<String> pass(NoteFilter f) => [
for (final n in [idea, todo, utils])
if (matchesFilter(n, NoteFilter(sources: sources))) n.id,
if (matchesFilter(n, f)) n.id,
];
test('empty means every note', () {
expect(pass({}), ['idea', 't', 'u']);
test('empty means every note, ideas included', () {
expect(pass(const NoteFilter()), ['idea', 't', 'u']);
expect(const NoteFilter().isEmpty, isTrue);
});
test('ideas only', () => expect(pass({kIdeasSource}), ['idea']));
test('the Repo tasks source lets every repo task in', () {
expect(pass({kRepoTasksSource}), ['t', 'u']);
expect(pass({kIdeasSource, kRepoTasksSource}), ['idea', 't', 'u']);
test('repoTasksOnly drops ideas', () {
expect(pass(const NoteFilter(repoTasksOnly: true)), ['t', 'u']);
});
test('a repo source lets only that repo in', () {
expect(pass({'kuhyx/utils'}), ['u']);
expect(pass({kIdeasSource, 'kuhyx/todo'}), ['idea', 't']);
test('repo is plain equality', () {
expect(pass(const NoteFilter(repo: 'kuhyx/utils')), ['u']);
expect(pass(const NoteFilter(repo: 'utils')), isEmpty);
});
test('ANDs with the other facets', () {
final f = const NoteFilter(
sources: {kRepoTasksSource},
).copyWith(query: 'zzz');
const f = NoteFilter(repoTasksOnly: true, query: 'zzz');
expect(matchesFilter(todo, f), isFalse);
});
test('counts as one facet and survives copyWith', () {
const f = NoteFilter(sources: {kIdeasSource});
test('each counts as one facet and survives copyWith', () {
const f = NoteFilter(repo: 'a/b', repoTasksOnly: true);
expect(f.isEmpty, isFalse);
expect(f.activeCount, 1);
expect(f.copyWith(query: 'q').sources, {kIdeasSource});
expect(f.copyWith(sources: {}).sources, isEmpty);
expect(f.activeCount, 2);
final g = f.copyWith(query: 'q');
expect(g.repo, 'a/b');
expect(g.repoTasksOnly, isTrue);
expect(f.copyWith(repo: 'c/d', repoTasksOnly: false).repo, 'c/d');
expect(f.copyWith(repoTasksOnly: false).repoTasksOnly, isFalse);
});
test('search also matches the repo name', () {

View File

@ -8,6 +8,15 @@ import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
/// Opens a date field's menu and picks [entry].
Future<void> pickRange(WidgetTester tester, String field, String entry) async {
await tester.tap(find.byTooltip(field));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text(entry).last);
await tester.pump();
}
void main() {
testWidgets('a Created date preset sets a created range on the filter', (
tester,
@ -17,10 +26,7 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// "Today" appears under both Created and Last-updated; the first is
// Created.
await tester.tap(find.text('Today').first);
await tester.pump();
await pickRange(tester, 'Created', 'Today');
await tapApply(tester);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
@ -93,10 +99,8 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('7 days').first); // Created: last 7 days
await tester.pump();
await tester.tap(find.text('Any').first); // clear that range
await tester.pump();
await pickRange(tester, 'Created', '7 days');
await pickRange(tester, 'Created', 'Any'); // clear that range
await tapApply(tester);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
@ -112,8 +116,7 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Custom…').first);
await tester.pump();
await pickRange(tester, 'Created', 'Custom…');
await tester.pump(const Duration(milliseconds: 300)); // picker opens
// The full-screen range picker dismisses via a close icon, not a label.
await tester.tap(find.byIcon(Icons.close));
@ -137,9 +140,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('High')); // priority chip toggle
// Second "Today" belongs to the Last-updated section.
await tester.tap(find.text('Today').last);
await tester.pump();
await pickRange(tester, 'Last updated', 'Today');
await tapApply(tester);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
@ -158,12 +159,10 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('30 days').first); // _applyDays(30)
await tester.pump();
await pickRange(tester, 'Created', '30 days');
// Custom… opens the range picker seeded with the 30-day range
// (initialDateRange != null); confirming with Save returns that range.
await tester.tap(find.text('Custom…').first);
await tester.pump();
await pickRange(tester, 'Created', 'Custom…');
await tester.pump(const Duration(milliseconds: 300)); // picker opens
await tester.tap(find.text('Save'));
await tester.pump();

View File

@ -34,8 +34,9 @@ Future<FakeNoteRepository> pumpList(
WidgetTester tester, {
List<Note> seed = const [],
bool advancedMode = true,
bool applyFilter = false,
}) async {
final repo = FakeNoteRepository(seed);
final repo = FakeNoteRepository(seed, applyFilter);
addTearDown(repo.close);
final settings = ValueNotifier(AppSettings(advancedMode: advancedMode));
addTearDown(settings.dispose);

View File

@ -0,0 +1,99 @@
// One list: ideas and repo groups together, repo searchable, and a filter
// sheet that fits a Pixel 6a screen without scrolling.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'notes_list_harness.dart';
Note _repoNote(
String id,
String title,
String repo, {
Status status = Status.todo,
}) => note(id, title, status: status).copyWith(repo: repo, by: 'claude');
final _seed = [
note('i', 'An idea about tea'),
_repoNote('r1', 'Fix the scanner', 'kuhyx/kartka'),
_repoNote('r2', 'Add barcode cache', 'kuhyx/kartka'),
_repoNote('r3', 'Closed chore', 'kuhyx/kartka', status: Status.done),
];
void _pixel6a(WidgetTester tester) {
tester.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 132, bottom: 63)
..viewPadding = const FakeViewPadding(top: 132, bottom: 63);
addTearDown(tester.view.reset);
}
void main() {
testWidgets('an idea and a repo group share the default list', (
tester,
) async {
_pixel6a(tester);
await pumpList(tester, seed: _seed, applyFilter: true);
expect(find.text('An idea about tea'), findsOneWidget);
// Two open tasks of one repo collapse to ONE row; the done one is
// filtered out before grouping.
expect(find.text('kartka · 2'), findsOneWidget);
expect(find.text('Fix the scanner'), findsNothing);
});
testWidgets('searching a repo name finds its group only', (tester) async {
_pixel6a(tester);
await pumpList(tester, seed: _seed, applyFilter: true);
await tester.enterText(find.byType(TextField), 'kartka');
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.text('kartka · 2'), findsOneWidget);
expect(find.text('An idea about tea'), findsNothing);
});
testWidgets('search narrows a group and hides it when nothing passes', (
tester,
) async {
_pixel6a(tester);
await pumpList(tester, seed: _seed, applyFilter: true);
await tester.enterText(find.byType(TextField), 'scanner');
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.text('kartka · 1'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'zzz');
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(find.textContaining('kartka'), findsNothing);
});
testWidgets('wide windows group too, in list rows', (tester) async {
await pumpList(tester, seed: _seed, applyFilter: true);
expect(find.text('kartka · 2'), findsOneWidget);
expect(find.text('Fix the scanner'), findsNothing);
});
testWidgets('a filter sheet fits a Pixel 6a screen with no scrolling', (
tester,
) async {
_pixel6a(tester);
await pumpList(tester, seed: _seed, applyFilter: true);
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final inSheet = find.descendant(
of: find.byType(BottomSheet),
matching: find.byType(Scrollable),
);
final states = tester.stateList<ScrollableState>(inSheet).toList();
for (final s in states) {
if (axisDirectionToAxis(s.axisDirection) == Axis.vertical)
expect(s.position.maxScrollExtent, 0);
}
expect(find.text('Apply').hitTestable(), findsOneWidget);
expect(find.text('Repo tasks'), findsNothing);
expect(find.text('Ideas'), findsNothing);
expect(find.byType(ActionChip), findsNothing);
});
}

View File

@ -1,9 +1,9 @@
// Repo tasks in the notes list: hidden by default, one chip per repo plus a
// "Repo tasks" chip in the filter sheet, fields read-only in the note sheet.
// Repo tasks in the notes list: no source filter at all, and their fields
// read-only in the note sheet.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/notes_list_defaults.dart';
import 'package:todo/ui/note_actions_sheet.dart';
import 'notes_list_harness.dart';
@ -16,89 +16,20 @@ Note task(String id, String repo, {String? evidence}) =>
evidence: evidence,
);
final _seed = [
note('i', 'An idea'),
task('t', 'kuhyx/utils'),
task('u', 'kuhyx/todo'),
task('v', 'kuhyx/todo'),
];
Future<void> openSheet(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
Future<void> apply(WidgetTester tester) async {
await tester.ensureVisible(find.text('Apply'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
bool chipSelected(WidgetTester tester, String label) =>
tester.widget<FilterChip>(find.widgetWithText(FilterChip, label)).selected;
bool badgeShown(WidgetTester tester) =>
tester.widget<Badge>(find.byType(Badge)).isLabelVisible;
void main() {
testWidgets('the default view shows only ideas, with no badge', (
testWidgets('the default view filters by status only, with no badge', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
expect(repo.lastFilter!.sources, {kIdeasSource});
expect(badgeShown(tester), isFalse);
});
testWidgets('the sheet offers Ideas, Repo tasks and one chip per repo', (
tester,
) async {
await pumpList(tester, seed: _seed);
await openSheet(tester);
expect(chipSelected(tester, 'Ideas'), isTrue);
expect(chipSelected(tester, 'Repo tasks'), isFalse);
expect(find.widgetWithText(FilterChip, 'kuhyx/todo'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'kuhyx/utils'), findsOneWidget);
expect(chipSelected(tester, 'kuhyx/todo'), isFalse);
});
testWidgets('Repo tasks lets every repo task in and shows the badge', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
await openSheet(tester);
await tester.tap(find.text('Repo tasks'));
await tester.pump();
await apply(tester);
expect(repo.lastFilter!.sources, {kIdeasSource, kRepoTasksSource});
expect(badgeShown(tester), isTrue);
});
testWidgets('a repo chip, without Ideas, shows only that repo', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
await openSheet(tester);
await tester.tap(find.text('Ideas'));
await tester.tap(find.text('kuhyx/utils'));
await tester.pump();
await apply(tester);
expect(repo.lastFilter!.sources, {'kuhyx/utils'});
});
testWidgets('Clear all resets the source chips to ideas only', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
await openSheet(tester);
await tester.tap(find.text('kuhyx/todo'));
await tester.pump();
await tester.tap(find.text('Clear all'));
await tester.pump();
await apply(tester);
expect(repo.lastFilter!.sources, {kIdeasSource});
final repo = await pumpList(
tester,
seed: [note('i', 'An idea'), task('t', 'kuhyx/utils')],
);
expect(repo.lastFilter!.statuses, kDefaultStatuses);
expect(repo.lastFilter!.repo, isNull);
expect(repo.lastFilter!.repoTasksOnly, isFalse);
expect(badgeShown(tester), isFalse);
});
@ -127,14 +58,6 @@ void main() {
expect(find.byType(TextField), findsNothing);
});
testWidgets('no repo tasks stored: the sheet has no source chips', (
tester,
) async {
await pumpList(tester, seed: [note('i', 'An idea')]);
await openSheet(tester);
expect(find.text('Repo tasks'), findsNothing);
});
testWidgets('a plain idea shows no task fields', (tester) async {
await pumpActions(tester, note('i', 'An idea'));
expect(find.text('Repo'), findsNothing);

129
test/repo_screen_test.dart Normal file
View File

@ -0,0 +1,129 @@
// The repo screen: all of a repo's tasks, each opening its note, plus the
// copy-all action.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
import 'package:todo/ui/note_tile.dart';
import 'notes_list_harness.dart';
Note _task(String id, String text, {String repo = 'kuhyx/kartka'}) =>
note(id, text).copyWith(repo: repo, by: 'claude');
final _seed = [
_task('aaaaaaaa-1111', '# Fix scanner\n\nIt crashes on EAN-8.'),
_task('bbbbbbbb-2222', 'Add cache'),
_task('cccccccc-3333', 'Other repo task', repo: 'kuhyx/utils'),
];
Future<void> _openRepo(WidgetTester tester) async {
await tester.tap(find.text('kartka · 2'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
}
void main() {
testWidgets('tapping the repo row lists all its tasks', (tester) async {
await pumpList(tester, seed: _seed, applyFilter: true);
await _openRepo(tester);
expect(find.text('Fix scanner'), findsOneWidget);
expect(find.text('Add cache'), findsOneWidget);
expect(find.text('Other repo task'), findsNothing);
});
testWidgets('phone grid: the repo tile opens the screen; long-press acts', (
tester,
) async {
tester.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = 2.625;
addTearDown(tester.view.reset);
await pumpList(tester, seed: _seed, applyFilter: true);
await _openRepo(tester);
await tester.longPress(find.text('Add cache'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Delete note'), findsOneWidget);
});
testWidgets('a task on the repo screen opens its note detail', (
tester,
) async {
await pumpList(tester, seed: _seed, applyFilter: true);
await _openRepo(tester);
await tester.tap(find.text('Add cache'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('View'), findsOneWidget);
});
testWidgets('copy puts every task title, body and id8 on the clipboard', (
tester,
) async {
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copied = (call.arguments as Map)['text'] as String?;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await pumpList(tester, seed: _seed, applyFilter: true);
await _openRepo(tester);
await tester.tap(find.byTooltip('Copy all'));
await tester.pump();
expect(copied, isNotNull);
expect(copied, contains('Fix scanner'));
expect(copied, contains('It crashes on EAN-8.'));
expect(copied, contains('id: aaaaaaaa'));
expect(copied, contains('Add cache'));
expect(copied, contains('id: bbbbbbbb'));
expect(copied, isNot(contains('Other repo task')));
expect(copied, contains('\n\n'));
});
testWidgets('cards show the bare repo name; an idea shows none', (
tester,
) async {
Future<void> pumpCards() => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
NoteTile(note: _seed[0], onTap: () {}, onActions: () {}),
SizedBox(
height: 120,
child: NoteGridTile(
note: _seed[1],
onTap: () {},
onActions: () {},
),
),
SizedBox(
height: 120,
child: NoteGridTile(
note: note('i', 'Just an idea'),
onTap: () {},
onActions: () {},
),
),
],
),
),
),
);
await pumpCards();
expect(find.textContaining('kartka'), findsNWidgets(2));
expect(find.textContaining('kuhyx/'), findsNothing);
});
}