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
4 changed files with 21688 additions and 71 deletions

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 {

View file

@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/data/filter_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
void main() {
test('a filter comes back as it went', () {
const filter = FilterContent(
name: 'Urgent',
query: 'today & p1',
description: "What can't wait",
color: PaletteColor.cobalt,
favorite: true,
position: 3,
);
expect(decodeFilter(encodeFilter(filter)), filter);
});
test('the optional parts stay out of a plain filter', () {
const filter = FilterContent(name: 'Mine', query: '#Home');
expect(decodeFilter(encodeFilter(filter)), filter);
});
test('a filter of a later schema is not read', () {
expect(
decodeFilter(
encodeJson(filterSchemaVersion + 1, {'name': 'New', 'query': 'today'}),
),
isNull,
);
});
test('a label keeps being a favourite, and older labels are none', () {
const label = LabelContent(name: 'errand', favorite: true);
expect(decodeLabel(encodeLabel(label)), label);
expect(
decodeLabel(encodeLabel(const LabelContent(name: 'old')))?.favorite,
isFalse,
);
});
}

View file

@ -0,0 +1,252 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// Filters' language (ADR 0033, D35), on the device alone.
void main() {
const today = Day(2026, 11, 6);
const world = FilterWorld(
today: today,
projects: {
'work': (name: 'Halkyon Work', parentId: null),
'docs': (name: 'Docs', parentId: 'work'),
'home': (name: 'Zuhause', parentId: null),
},
sections: {'errands': 'Errands'},
labels: {'l1': 'Einkauf', 'l2': 'waiting'},
);
OpenTask task({
String project = 'home',
String? section,
String? parent,
String title = 'Task',
String description = '',
TaskDate? date,
Day? deadline,
Priority priority = Priority.none,
List<String> labels = const [],
DateTime? done,
}) => (
id: title,
content: TaskContent(
title: title,
description: description,
projectId: project,
sectionId: section,
parentId: parent,
date: date,
deadline: deadline,
priority: priority,
labelIds: labels,
completedAt: done,
),
);
FilterQuery query(String text) => switch (FilterQuery.parse(text)) {
FilterRead(:final query) => query,
FilterUnread(:final near) => fail('$text not read, near "$near"'),
};
bool hits(String text, OpenTask task) => query(text).matches(task, world);
group('dates', () {
final due = task(date: const TaskDate(today));
final tomorrow = task(date: TaskDate(today.plus(1)));
final late = task(date: TaskDate(today.plus(-1)));
final none = task();
test('today, tomorrow, yesterday', () {
expect(hits('today', due), isTrue);
expect(hits('today', tomorrow), isFalse);
expect(hits('tomorrow', tomorrow), isTrue);
expect(hits('yesterday', late), isTrue);
});
test('overdue is what is dated before today', () {
expect(hits('overdue', late), isTrue);
expect(hits('od', late), isTrue);
expect(hits('overdue', due), isFalse);
expect(hits('overdue', none), isFalse);
});
test('a number of days starts today, and leaves out the overdue', () {
expect(hits('7 days', due), isTrue);
expect(hits('7 days', task(date: TaskDate(today.plus(6)))), isTrue);
expect(hits('7 days', task(date: TaskDate(today.plus(7)))), isFalse);
expect(hits('next 7 days', late), isFalse);
});
test('no date, and a day named', () {
expect(hits('no date', none), isTrue);
expect(hits('no date', due), isFalse);
expect(hits('2026-11-06', due), isTrue);
expect(hits('before: 2026-11-07', due), isTrue);
expect(hits('after: 2026-11-06', due), isFalse);
expect(hits('after: 2026-11-05', due), isTrue);
expect(hits('before: 2026-11-07', none), isFalse);
});
test('a time, and a recurrence', () {
expect(hits('no time', due), isTrue);
expect(hits('no time', none), isFalse);
expect(
hits('no time', task(date: const TaskDate(today, time: 60))),
isFalse,
);
expect(hits('recurring', due), isFalse);
expect(
hits(
'recurring',
task(
date: const TaskDate(
today,
repeat: Repeat(RepeatUnit.day),
),
),
),
isTrue,
);
});
test('a deadline', () {
expect(hits('deadline', task(deadline: today)), isTrue);
expect(hits('deadline', none), isFalse);
expect(hits('no deadline', none), isTrue);
});
});
group('what a task belongs to', () {
test('priorities, p4 being none', () {
expect(hits('p1', task(priority: Priority.p1)), isTrue);
expect(hits('p1', task(priority: Priority.p2)), isFalse);
expect(hits('p4', task()), isTrue);
expect(hits('p4', task(priority: Priority.p3)), isFalse);
});
test('a project by its name, with spaces, whatever the case', () {
expect(hits('#halkyon work', task(project: 'work')), isTrue);
expect(hits('#Halkyon Work', task(project: 'docs')), isFalse);
});
test('## takes in its sub-projects', () {
expect(hits('##Halkyon Work', task(project: 'docs')), isTrue);
expect(hits('##Halkyon Work', task()), isFalse);
});
test('a section, a label, and no labels', () {
expect(hits('/errands', task(section: 'errands')), isTrue);
expect(hits('/errands', task()), isFalse);
expect(hits('@einkauf', task(labels: ['l1'])), isTrue);
expect(hits('@einkauf', task(labels: ['l2'])), isFalse);
expect(hits('no labels', task()), isTrue);
expect(hits('no labels', task(labels: ['l1'])), isFalse);
});
test('* stands for anything in a name', () {
expect(hits('#Halkyon*', task(project: 'work')), isTrue);
expect(hits('@*ing', task(labels: ['l2'])), isTrue);
expect(hits('@*ing', task(labels: ['l1'])), isFalse);
});
test('a name that is no project matches nothing, and is no mistake', () {
expect(hits('#Nowhere', task()), isFalse);
});
test('sub-tasks, and words in a task', () {
expect(hits('subtask', task(parent: 'x')), isTrue);
expect(hits('!subtask', task(parent: 'x')), isFalse);
expect(hits('search: milk', task(title: 'Buy MILK')), isTrue);
expect(hits('search: milk', task(description: 'milk')), isTrue);
expect(hits('search: milk', task(title: 'Buy eggs')), isFalse);
});
});
group('putting them together', () {
final urgent = task(priority: Priority.p1, date: const TaskDate(today));
final later = task(priority: Priority.p1);
test('& and |, & first', () {
expect(hits('today & p1', urgent), isTrue);
expect(hits('today & p1', later), isFalse);
expect(hits('today | p1', later), isTrue);
expect(hits('p2 | today & p1', urgent), isTrue);
expect(hits('p2 | today & p3', urgent), isFalse);
});
test('! and parentheses', () {
expect(hits('!today', later), isTrue);
expect(hits('!today', urgent), isFalse);
expect(hits('!!today', urgent), isTrue);
expect(hits('(p2 | p1) & today', urgent), isTrue);
expect(hits('!(p1 & today)', urgent), isFalse);
});
test('never what is done', () {
expect(
hits('p1', task(priority: Priority.p1, done: DateTime(2026))),
isFalse,
);
expect(hits('!p2', task(done: DateTime(2026))), isFalse);
});
});
group('German', () {
test('its words read as the English ones', () {
final due = task(date: const TaskDate(today));
expect(hits('heute', due), isTrue);
expect(hits('Morgen', task(date: TaskDate(today.plus(1)))), isTrue);
expect(hits('überfällig', task(date: TaskDate(today.plus(-1)))), isTrue);
expect(hits('ohne datum', task()), isTrue);
expect(hits('7 tage', due), isTrue);
expect(hits('nächste 7 tage', due), isTrue);
expect(hits('ohne labels', task()), isTrue);
expect(hits('wiederkehrend', due), isFalse);
expect(hits('frist', task(deadline: today)), isTrue);
expect(hits('suche: milch', task(title: 'Milch')), isTrue);
});
});
group('what it refuses, saying where', () {
String? near(String text) => switch (FilterQuery.parse(text)) {
FilterRead() => null,
FilterUnread(:final near) => near,
};
test('a word it does not know', () {
expect(near('today & someday'), 'someday');
expect(near('banana'), 'banana');
});
test('nothing at all, and what is left dangling', () {
expect(near(''), '');
expect(near(' '), '');
expect(near('today &'), '');
expect(near('| today'), '| today');
expect(near('(today'), '');
expect(near('today)'), ')');
expect(near('#'), '#');
expect(near('before: soon'), 'before: soon');
expect(near('0 days'), '0 days');
});
});
group('the tasks a filter shows', () {
test('dated first, the nearest first, then by priority', () {
final tasks = [
task(title: 'none', priority: Priority.p1),
task(title: 'later', date: TaskDate(today.plus(2))),
task(title: 'soon', date: const TaskDate(today)),
];
expect(
[
for (final task in tasksOfFilter(query('p1 | !p1'), tasks, world))
task.id,
],
['soon', 'later', 'none'],
);
});
});
}

View file

@ -5,6 +5,8 @@ import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.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/label/data/label_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
@ -501,5 +503,18 @@ void main() {
LabelRepository(rows, await keysOf(null)).add(label),
throwsA(isA<KeysMissing>()),
);
final filters = FilterRepository(rows, await keysOf(alice));
const filter = FilterContent(name: 'Urgent', query: 'p1');
await filters.add(filter);
expect((await filters.all()).last.content, filter);
expect(
(await FilterRepository(rows, await keysOf(bob)).all()).last.content,
isNull,
);
await expectLater(
FilterRepository(rows, await keysOf(null)).add(filter),
throwsA(isA<KeysMissing>()),
);
});
}