diff --git a/AGENTS.md b/AGENTS.md index 0bf470b..e1110ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/lib/cli/cli_commands.dart b/lib/cli/cli_commands.dart index eea80f4..e6536e1 100644 --- a/lib/cli/cli_commands.dart +++ b/lib/cli/cli_commands.dart @@ -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); diff --git a/lib/data/note.dart b/lib/data/note.dart index 4f4ce69..a15969d 100644 --- a/lib/data/note.dart +++ b/lib/data/note.dart @@ -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, diff --git a/lib/data/note_query.dart b/lib/data/note_query.dart index a299ae2..443a897 100644 --- a/lib/data/note_query.dart +++ b/lib/data/note_query.dart @@ -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 statuses; - /// Which notes may show: [kIdeasSource], [kRepoTasksSource] and/or repo - /// names. Empty means every note. - final Set 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? priorities, Set? statuses, - Set? 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 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) { diff --git a/lib/mcp/task_service.dart b/lib/mcp/task_service.dart index f8eb775..e8a572d 100644 --- a/lib/mcp/task_service.dart +++ b/lib/mcp/task_service.dart @@ -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), ), ); }); diff --git a/lib/ui/note_date_range_field.dart b/lib/ui/note_date_range_field.dart index 2779a04..fd6e01e 100644 --- a/lib/ui/note_date_range_field.dart +++ b/lib/ui/note_date_range_field.dart @@ -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]; +} diff --git a/lib/ui/note_grid_tile.dart b/lib/ui/note_grid_tile.dart index c1f38e4..d6444b0 100644 --- a/lib/ui/note_grid_tile.dart +++ b/lib/ui/note_grid_tile.dart @@ -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( diff --git a/lib/ui/note_tile.dart b/lib/ui/note_tile.dart index e088a9d..7260ae4 100644 --- a/lib/ui/note_tile.dart +++ b/lib/ui/note_tile.dart @@ -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( diff --git a/lib/ui/notes_filter_sheet.dart b/lib/ui/notes_filter_sheet.dart index 10b8dae..5582904 100644 --- a/lib/ui/notes_filter_sheet.dart +++ b/lib/ui/notes_filter_sheet.dart @@ -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 repos; - @override State createState() => _FilterSheetState(); } -const Map _sourceLabels = { - kIdeasSource: 'Ideas', - kRepoTasksSource: 'Repo tasks', -}; - class _FilterSheetState extends State { late Set _priorities = {...widget.initial.priorities}; late Set _statuses = {...widget.initial.statuses}; - late Set _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 { // 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 { return NoteFilter( priorities: _priorities, statuses: _statuses, - sources: _sources, createdFrom: _createdFrom, createdTo: _createdTo, updatedFrom: _updatedFrom, @@ -102,16 +91,6 @@ class _FilterSheetState extends State { ], ), const SizedBox(height: 8), - if (widget.repos.isNotEmpty) ...[ - _MultiChips( - label: 'Show', - values: [kIdeasSource, kRepoTasksSource, ...widget.repos], - selected: _sources, - labelOf: (s) => _sourceLabels[s] ?? s, - onToggle: (s) => _toggle(_sources, s), - ), - const SizedBox(height: 12), - ], _MultiChips( label: 'Status', values: Status.values, diff --git a/lib/ui/notes_grid.dart b/lib/ui/notes_grid.dart index fdd155f..556b8a6 100644 --- a/lib/ui/notes_grid.dart +++ b/lib/ui/notes_grid.dart @@ -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 notes; + /// Rows in display order: single notes and repo groups. + final List entries; /// Tap on a tile. final ValueChanged onOpen; @@ -38,14 +41,22 @@ class NotesGrid extends StatelessWidget { /// Long-press on a tile. final ValueChanged onActions; - Widget _tile(Note note) { - return NoteGridTile( + /// Tap on a repo group tile. + final ValueChanged 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]), ), ), ), diff --git a/lib/ui/notes_list_body.dart b/lib/ui/notes_list_body.dart new file mode 100644 index 0000000..abb3c19 --- /dev/null +++ b/lib/ui/notes_list_body.dart @@ -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 entries; + + /// Tap on a note. + final ValueChanged onOpen; + + /// Long-press (or the actions button) on a note. + final ValueChanged onActions; + + /// Tap on a repo group. + final ValueChanged 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), + ), + }, + ), + ); + } +} diff --git a/lib/ui/notes_list_defaults.dart b/lib/ui/notes_list_defaults.dart index 124f268..113eec0 100644 --- a/lib/ui/notes_list_defaults.dart +++ b/lib/ui/notes_list_defaults.dart @@ -4,20 +4,13 @@ import 'package:todo/data/note_repository.dart'; /// The default status selection: hide completed/dropped work. const Set kDefaultStatuses = {Status.todo, Status.inProgress}; -/// The default source selection: plain ideas only, repo tasks hidden. -const Set 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; } diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 5b3c9a7..41f28d1 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -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 { 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 { /// 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 _openFilters() async { - final repos = { - for (final n in await widget.repository.listNotes()) ?n.repo, - }.toList()..sort(); - if (!mounted) return; final edited = await showModalBottomSheet( 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 { } } + Future _openRepo(String repo) async { + FocusManager.instance.primaryFocus?.unfocus(); + await Navigator.of(context).push( + 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 { 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, ); }, ), diff --git a/lib/ui/repo_group.dart b/lib/ui/repo_group.dart new file mode 100644 index 0000000..3929d85 --- /dev/null +++ b/lib/ui/repo_group.dart @@ -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 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 groupByRepo(List notes) { + final groups = >{}; + final entries = []; + 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: ` per task, +/// tasks separated by a blank line. +String repoClipboardText(List 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'); +} diff --git a/lib/ui/repo_screen.dart b/lib/ui/repo_screen.dart new file mode 100644 index 0000000..fbbb870 --- /dev/null +++ b/lib/ui/repo_screen.dart @@ -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: `) 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; + + @override + State createState() => _RepoScreenState(); +} + +class _RepoScreenState extends State { + late final Stream> _stream = widget.repository.watchNotes( + sort: widget.sort, + filter: widget.filter.copyWith(repo: widget.repo), + ); + List _notes = const []; + bool _copied = false; + + Future _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>( + stream: _stream, + builder: (context, snapshot) { + _notes = snapshot.data ?? const []; + 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), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/ui/repo_tiles.dart b/lib/ui/repo_tiles.dart new file mode 100644 index 0000000..d1e98f2 --- /dev/null +++ b/lib/ui/repo_tiles.dart @@ -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, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a82f821..cb6055f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/fake_note_repository.dart b/test/fake_note_repository.dart index b0fba7e..0b902e9 100644 --- a/test/fake_note_repository.dart +++ b/test/fake_note_repository.dart @@ -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? initial]) : _notes = [...?initial]; + FakeNoteRepository([List? 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 _notes; final _controller = StreamController>.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 diff --git a/test/note_task_fields_test.dart b/test/note_task_fields_test.dart index 0668a92..aa94e9c 100644 --- a/test/note_task_fields_test.dart +++ b/test/note_task_fields_test.dart @@ -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 pass(Set sources) => [ + List 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', () { diff --git a/test/notes_list_filter_test.dart b/test/notes_list_filter_test.dart index d567efa..040f389 100644 --- a/test/notes_list_filter_test.dart +++ b/test/notes_list_filter_test.dart @@ -8,6 +8,15 @@ import 'fake_note_repository.dart'; import 'notes_list_harness.dart'; +/// Opens a date field's menu and picks [entry]. +Future 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(); diff --git a/test/notes_list_harness.dart b/test/notes_list_harness.dart index aa34b70..80714d9 100644 --- a/test/notes_list_harness.dart +++ b/test/notes_list_harness.dart @@ -34,8 +34,9 @@ Future pumpList( WidgetTester tester, { List 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); diff --git a/test/notes_list_one_list_test.dart b/test/notes_list_one_list_test.dart new file mode 100644 index 0000000..07cbe57 --- /dev/null +++ b/test/notes_list_one_list_test.dart @@ -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(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); + }); +} diff --git a/test/notes_list_repo_filter_test.dart b/test/notes_list_repo_filter_test.dart index 5ca03d0..056b094 100644 --- a/test/notes_list_repo_filter_test.dart +++ b/test/notes_list_repo_filter_test.dart @@ -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 openSheet(WidgetTester tester) async { - await tester.tap(find.byIcon(Icons.filter_list)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); -} - -Future 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(find.widgetWithText(FilterChip, label)).selected; - bool badgeShown(WidgetTester tester) => tester.widget(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); diff --git a/test/repo_screen_test.dart b/test/repo_screen_test.dart new file mode 100644 index 0000000..e465ec3 --- /dev/null +++ b/test/repo_screen_test.dart @@ -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 _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 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); + }); +}