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:
parent
a4c1fe298d
commit
b1f2078357
3 changed files with 21688 additions and 71 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue