feat(todo): filters in Todoist's language, and a view of each label (ADR 0033, D35)

A filter is a query read on the device, as the server sees no task, kept
sealed in `saved_filter`. Filters & labels is a screen of its own under
Browse; a label opens its tasks, and favourite filters and labels join
Browse's Favourites. D36 records what of Todoist's Pro stays out.

Whats-New: Filters: keep views like "today & p1", and open a label to see its tasks, under Browse in Filters & labels.
Whats-New-De: Filter: Speichere Ansichten wie „heute & p1“ und öffne ein Label, um seine Aufgaben zu sehen, in der Übersicht unter Filter & Labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-06 19:44:46 +01:00
commit b1f2078357
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -9,6 +9,10 @@ import 'package:halkyon_app/testing.dart';
import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/data/filter_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filters_screen.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
@ -32,6 +36,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dar
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
@ -63,6 +68,7 @@ void main() {
late MemorySealedRowGateway sectionRows;
late MemorySealedRowGateway taskRows;
late MemorySealedRowGateway labelRows;
late MemorySealedRowGateway filterRows;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late MemoryLocationGateway locationGateway;
@ -93,6 +99,7 @@ void main() {
sectionRows = MemorySealedRowGateway();
taskRows = MemorySealedRowGateway();
labelRows = MemorySealedRowGateway();
filterRows = MemorySealedRowGateway();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
locationGateway = MemoryLocationGateway();
@ -195,6 +202,7 @@ void main() {
(await settings.read()).reminders.automatic,
),
labels: LabelRepository(labelRows, keys),
filters: FilterRepository(filterRows, keys),
settings: settings,
reminders: reminders,
locationReminders: locationGateway,
@ -391,9 +399,159 @@ void main() {
expect(find.text('Nothing to do here'), findsOneWidget);
await goTab(tester, 'Browse');
expect(find.text('No projects of your own yet.'), findsOneWidget);
await tester.tap(find.byKey(filtersEntryKey));
await tester.pumpAndSettle();
expect(find.text('No filters yet.'), findsOneWidget);
expect(find.text('No labels yet.'), findsOneWidget);
});
group('filters and labels (D35)', () {
Future<void> openFilters(WidgetTester tester) async {
await goTab(tester, 'Browse');
await tester.tap(find.byKey(filtersEntryKey));
await tester.pumpAndSettle();
}
Future<void> tasksOf(
WidgetTester tester,
Map<String, ({Priority priority, List<String> labels})> all,
) async {
await tester.runAsync(() async {
final inbox = await ProjectRepository(
projectRows,
keys,
).ensureInbox();
final repository = TaskRepository(taskRows, keys);
for (final MapEntry(key: title, value: task) in all.entries) {
await repository.add(
TaskContent(
title: title,
projectId: inbox,
priority: task.priority,
labelIds: task.labels,
),
);
}
});
projectRows.bringRemoteChanges();
taskRows.bringRemoteChanges();
await tester.pumpAndSettle();
}
testWidgets('a new filter says what it finds as it is typed, and '
"can't be kept while it isn't understood", (tester) async {
await start(tester, usedBefore: true);
await tasksOf(tester, const {
'Pay rent': (priority: Priority.p1, labels: []),
'Water plants': (priority: Priority.none, labels: []),
});
await openFilters(tester);
await tester.tap(find.byKey(filterAddKey));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(filterNameKey), 'Urgent');
await tester.enterText(find.byKey(filterQueryKey), 'p1 & (');
await tester.pumpAndSettle();
expect(find.text('This query ends too soon'), findsOneWidget);
expect(
tester.widget<FilledButton>(find.byKey(filterSaveKey)).onPressed,
isNull,
);
await tester.enterText(find.byKey(filterQueryKey), 'p1');
await tester.pumpAndSettle();
expect(find.text('1 task matches'), findsOneWidget);
await tester.tap(find.byKey(filterSaveKey));
await tester.pumpAndSettle();
filterRows.bringRemoteChanges();
await tester.pumpAndSettle();
await tester.tap(find.text('Urgent'));
await tester.pumpAndSettle();
expect(find.text('Pay rent'), findsOneWidget);
expect(find.text('Water plants'), findsNothing);
});
testWidgets('a favourite filter and a favourite label show in Browse; '
'others stay on their screen', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(() async {
final filters = FilterRepository(filterRows, keys);
await filters.add(
const FilterContent(name: 'Kept', query: 'p1', favorite: true),
);
await filters.add(const FilterContent(name: 'Plain', query: 'p2'));
await LabelRepository(
labelRows,
keys,
).add(const LabelContent(name: 'errand', favorite: true));
});
filterRows.bringRemoteChanges();
labelRows.bringRemoteChanges();
await tester.pumpAndSettle();
await goTab(tester, 'Browse');
expect(find.text('Kept'), findsOneWidget);
expect(find.text('errand'), findsOneWidget);
expect(find.text('Plain'), findsNothing);
});
testWidgets("a label's screen lists its open tasks, those alone", (
tester,
) async {
await start(tester, usedBefore: true);
final errand = await tester.runAsync(
() => LabelRepository(
labelRows,
keys,
).add(const LabelContent(name: 'errand')),
);
labelRows.bringRemoteChanges();
await tasksOf(tester, {
'Post office': (priority: Priority.none, labels: [errand!]),
'Read a book': (priority: Priority.none, labels: const []),
});
await openFilters(tester);
await tester.tap(find.text('errand'));
await tester.pumpAndSettle();
expect(find.text('Post office'), findsOneWidget);
expect(find.text('Read a book'), findsNothing);
});
testWidgets('a filter deleted is gone, after asking', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => FilterRepository(
filterRows,
keys,
).add(const FilterContent(name: 'Urgent', query: 'p1')),
);
filterRows.bringRemoteChanges();
await tester.pumpAndSettle();
await openFilters(tester);
await tester.tap(find.text('Urgent'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Filter menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete filter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete filter').last);
await tester.pumpAndSettle();
filterRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(
await tester.runAsync(
() => FilterRepository(filterRows, keys).all(),
),
isEmpty,
);
});
});
testWidgets('the tabs wash down from the top in strawberry', (
tester,
) async {