mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
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
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:
parent
9b42ff8ba2
commit
67212cd7ec
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
66
lib/ui/notes_list_body.dart
Normal file
66
lib/ui/notes_list_body.dart
Normal 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),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
70
lib/ui/repo_group.dart
Normal 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
99
lib/ui/repo_screen.dart
Normal 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
73
lib/ui/repo_tiles.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
99
test/notes_list_one_list_test.dart
Normal file
99
test/notes_list_one_list_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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
129
test/repo_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user