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

View file

@ -31,6 +31,30 @@
"deadlineOn": "Frist {day}",
"deadlineNone": "Keine Frist",
"keysMissing": "Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.",
"filtersLabelsTitle": "Filter & Labels",
"filtersLabelsHint": "Gespeicherte Ansichten deiner Aufgaben",
"filtersTitle": "Meine Filter",
"filtersNone": "Noch keine Filter.",
"filterAdd": "Neuer Filter",
"filterEdit": "Filter bearbeiten",
"filterDelete": "Filter löschen",
"filterDeleteTitle": "Filter löschen?",
"filterName": "Name",
"filterDescription": "Beschreibung",
"filterQuery": "Abfrage",
"filterQueryHint": "heute & p1",
"filterFavorite": "Zu Favoriten hinzufügen",
"filterUnfavorite": "Aus Favoriten entfernen",
"filterPreviewEmpty": "Noch passt nichts.",
"filterNothing": "Gerade sind keine Aufgaben in diesem Filter.",
"filterHelp": "heute, morgen, überfällig, ohne datum, 7 tage, p1 bis p4, #Projekt, ##Projekt mit Unterprojekten, /Abschnitt, @label, suche: Wort. Verbinde sie mit & (und), | (oder), ! (nicht) und Klammern; * steht für Beliebiges in einem Namen.",
"filterMenu": "Filtermenü",
"labelMenu": "Labelmenü",
"labelNothing": "Keine offene Aufgabe hat dieses Label.",
"labelEditEntry": "Label bearbeiten",
"filterMatches": "{count, plural, =1{1 Aufgabe passt} other{{count} Aufgaben passen}}",
"filterNotRead": "Nicht verstanden bei „{near}“",
"filterNotReadEnd": "Die Abfrage endet zu früh",
"labelsTitle": "Labels",
"labelsNone": "Noch keine Labels.",
"labelsNoneSet": "Keine Labels",

View file

@ -72,6 +72,49 @@
},
"deadlineNone": "No deadline",
"keysMissing": "This device can't save: it doesn't hold your account's keys. Sign in with your password or recovery phrase to add them.",
"filtersLabelsTitle": "Filters & labels",
"filtersLabelsHint": "Views of your tasks, kept",
"filtersTitle": "My filters",
"filtersNone": "No filters yet.",
"filterAdd": "New filter",
"filterEdit": "Edit filter",
"filterDelete": "Delete filter",
"filterDeleteTitle": "Delete the filter?",
"filterName": "Name",
"filterDescription": "Description",
"filterQuery": "Query",
"filterQueryHint": "today & p1",
"filterFavorite": "Add to favourites",
"filterUnfavorite": "Remove from favourites",
"filterPreviewEmpty": "Nothing matches yet.",
"filterNothing": "No tasks in this filter at the moment.",
"filterHelp": "today, tomorrow, overdue, no date, 7 days, p1 to p4, #Project, ##Project with its sub-projects, /Section, @label, search: word. Combine them with & (and), | (or), ! (not) and parentheses; * stands for anything in a name.",
"filterMenu": "Filter menu",
"labelMenu": "Label menu",
"labelNothing": "No open tasks have this label.",
"labelEditEntry": "Edit label",
"filterMatches": "{count, plural, =1{1 task matches} other{{count} tasks match}}",
"@filterMatches": {
"description": "The filter editor's preview.",
"placeholders": {
"count": {
"type": "int"
}
}
},
"filterNotRead": "Not understood near “{near}”",
"@filterNotRead": {
"description": "The filter editor when its query cannot be read.",
"placeholders": {
"near": {
"type": "String"
}
}
},
"filterNotReadEnd": "This query ends too soon",
"@filterNotReadEnd": {
"description": "The filter editor when its query stops before it is complete."
},
"labelsTitle": "Labels",
"labelsNone": "No labels yet.",
"labelsNoneSet": "No labels",

View file

@ -286,6 +286,150 @@ abstract class AppLocalizations {
/// **'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.'**
String get keysMissing;
/// No description provided for @filtersLabelsTitle.
///
/// In en, this message translates to:
/// **'Filters & labels'**
String get filtersLabelsTitle;
/// No description provided for @filtersLabelsHint.
///
/// In en, this message translates to:
/// **'Views of your tasks, kept'**
String get filtersLabelsHint;
/// No description provided for @filtersTitle.
///
/// In en, this message translates to:
/// **'My filters'**
String get filtersTitle;
/// No description provided for @filtersNone.
///
/// In en, this message translates to:
/// **'No filters yet.'**
String get filtersNone;
/// No description provided for @filterAdd.
///
/// In en, this message translates to:
/// **'New filter'**
String get filterAdd;
/// No description provided for @filterEdit.
///
/// In en, this message translates to:
/// **'Edit filter'**
String get filterEdit;
/// No description provided for @filterDelete.
///
/// In en, this message translates to:
/// **'Delete filter'**
String get filterDelete;
/// No description provided for @filterDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the filter?'**
String get filterDeleteTitle;
/// No description provided for @filterName.
///
/// In en, this message translates to:
/// **'Name'**
String get filterName;
/// No description provided for @filterDescription.
///
/// In en, this message translates to:
/// **'Description'**
String get filterDescription;
/// No description provided for @filterQuery.
///
/// In en, this message translates to:
/// **'Query'**
String get filterQuery;
/// No description provided for @filterQueryHint.
///
/// In en, this message translates to:
/// **'today & p1'**
String get filterQueryHint;
/// No description provided for @filterFavorite.
///
/// In en, this message translates to:
/// **'Add to favourites'**
String get filterFavorite;
/// No description provided for @filterUnfavorite.
///
/// In en, this message translates to:
/// **'Remove from favourites'**
String get filterUnfavorite;
/// No description provided for @filterPreviewEmpty.
///
/// In en, this message translates to:
/// **'Nothing matches yet.'**
String get filterPreviewEmpty;
/// No description provided for @filterNothing.
///
/// In en, this message translates to:
/// **'No tasks in this filter at the moment.'**
String get filterNothing;
/// No description provided for @filterHelp.
///
/// In en, this message translates to:
/// **'today, tomorrow, overdue, no date, 7 days, p1 to p4, #Project, ##Project with its sub-projects, /Section, @label, search: word. Combine them with & (and), | (or), ! (not) and parentheses; * stands for anything in a name.'**
String get filterHelp;
/// No description provided for @filterMenu.
///
/// In en, this message translates to:
/// **'Filter menu'**
String get filterMenu;
/// No description provided for @labelMenu.
///
/// In en, this message translates to:
/// **'Label menu'**
String get labelMenu;
/// No description provided for @labelNothing.
///
/// In en, this message translates to:
/// **'No open tasks have this label.'**
String get labelNothing;
/// No description provided for @labelEditEntry.
///
/// In en, this message translates to:
/// **'Edit label'**
String get labelEditEntry;
/// The filter editor's preview.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 task matches} other{{count} tasks match}}'**
String filterMatches(int count);
/// The filter editor when its query cannot be read.
///
/// In en, this message translates to:
/// **'Not understood near “{near}”'**
String filterNotRead(String near);
/// The filter editor when its query stops before it is complete.
///
/// In en, this message translates to:
/// **'This query ends too soon'**
String get filterNotReadEnd;
/// No description provided for @labelsTitle.
///
/// In en, this message translates to:

View file

@ -120,6 +120,89 @@ class AppLocalizationsDe extends AppLocalizations {
String get keysMissing =>
'Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.';
@override
String get filtersLabelsTitle => 'Filter & Labels';
@override
String get filtersLabelsHint => 'Gespeicherte Ansichten deiner Aufgaben';
@override
String get filtersTitle => 'Meine Filter';
@override
String get filtersNone => 'Noch keine Filter.';
@override
String get filterAdd => 'Neuer Filter';
@override
String get filterEdit => 'Filter bearbeiten';
@override
String get filterDelete => 'Filter löschen';
@override
String get filterDeleteTitle => 'Filter löschen?';
@override
String get filterName => 'Name';
@override
String get filterDescription => 'Beschreibung';
@override
String get filterQuery => 'Abfrage';
@override
String get filterQueryHint => 'heute & p1';
@override
String get filterFavorite => 'Zu Favoriten hinzufügen';
@override
String get filterUnfavorite => 'Aus Favoriten entfernen';
@override
String get filterPreviewEmpty => 'Noch passt nichts.';
@override
String get filterNothing => 'Gerade sind keine Aufgaben in diesem Filter.';
@override
String get filterHelp =>
'heute, morgen, überfällig, ohne datum, 7 tage, p1 bis p4, #Projekt, ##Projekt mit Unterprojekten, /Abschnitt, @label, suche: Wort. Verbinde sie mit & (und), | (oder), ! (nicht) und Klammern; * steht für Beliebiges in einem Namen.';
@override
String get filterMenu => 'Filtermenü';
@override
String get labelMenu => 'Labelmenü';
@override
String get labelNothing => 'Keine offene Aufgabe hat dieses Label.';
@override
String get labelEditEntry => 'Label bearbeiten';
@override
String filterMatches(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Aufgaben passen',
one: '1 Aufgabe passt',
);
return '$_temp0';
}
@override
String filterNotRead(String near) {
return 'Nicht verstanden bei „$near“';
}
@override
String get filterNotReadEnd => 'Die Abfrage endet zu früh';
@override
String get labelsTitle => 'Labels';

View file

@ -120,6 +120,89 @@ class AppLocalizationsEn extends AppLocalizations {
String get keysMissing =>
'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.';
@override
String get filtersLabelsTitle => 'Filters & labels';
@override
String get filtersLabelsHint => 'Views of your tasks, kept';
@override
String get filtersTitle => 'My filters';
@override
String get filtersNone => 'No filters yet.';
@override
String get filterAdd => 'New filter';
@override
String get filterEdit => 'Edit filter';
@override
String get filterDelete => 'Delete filter';
@override
String get filterDeleteTitle => 'Delete the filter?';
@override
String get filterName => 'Name';
@override
String get filterDescription => 'Description';
@override
String get filterQuery => 'Query';
@override
String get filterQueryHint => 'today & p1';
@override
String get filterFavorite => 'Add to favourites';
@override
String get filterUnfavorite => 'Remove from favourites';
@override
String get filterPreviewEmpty => 'Nothing matches yet.';
@override
String get filterNothing => 'No tasks in this filter at the moment.';
@override
String get filterHelp =>
'today, tomorrow, overdue, no date, 7 days, p1 to p4, #Project, ##Project with its sub-projects, /Section, @label, search: word. Combine them with & (and), | (or), ! (not) and parentheses; * stands for anything in a name.';
@override
String get filterMenu => 'Filter menu';
@override
String get labelMenu => 'Label menu';
@override
String get labelNothing => 'No open tasks have this label.';
@override
String get labelEditEntry => 'Edit label';
@override
String filterMatches(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tasks match',
one: '1 task matches',
);
return '$_temp0';
}
@override
String filterNotRead(String near) {
return 'Not understood near “$near”';
}
@override
String get filterNotReadEnd => 'This query ends too soon';
@override
String get labelsTitle => 'Labels';

View file

@ -11,6 +11,8 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart'
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.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/data/sync_filter_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_access.dart';
@ -62,6 +64,9 @@ class TodoPerson(
/// Their labels, sealed and synced.
required final LabelRepository labels,
/// Their filters, sealed and synced (D35).
required final FilterRepository filters,
/// Their settings of Todo's (Upcoming's Display), sealed and synced.
required final SettingsRepository settings,
@ -406,6 +411,7 @@ Future<TodoPerson> _openPersonData(
automaticReminder: () async => (await settings.read()).reminders.automatic,
);
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
final filters = FilterRepository(SyncFilterGateway(db, sync), keys);
// The Inbox, made once by whichever device is first; nothing where this
// device holds none of the account's keys.
unawaited(
@ -422,6 +428,7 @@ Future<TodoPerson> _openPersonData(
sections: sections,
tasks: tasks,
labels: labels,
filters: filters,
settings: settings,
reminders: reminders,
notifications: notifications,
@ -434,6 +441,7 @@ Future<TodoPerson> _openPersonData(
await sections.dispose();
await tasks.dispose();
await labels.dispose();
await filters.dispose();
await settings.dispose();
await sync.dispose();
await db.close();

View file

@ -16,6 +16,15 @@ abstract final class TodoPaths() {
/// A project's screen, in Browse.
static String project(String id) => '/browse/projects/$id';
/// Filters and labels (D35), in Browse.
static const filters = '/browse/filters';
/// A filter's tasks.
static String filter(String id) => '/browse/filters/$id';
/// A label's tasks.
static String label(String id) => '/browse/labels/$id';
/// Settings, over the tabs.
static const settings = '/settings';

View file

@ -1,6 +1,8 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_screen.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filters_screen.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_screen.dart';
import 'package:halkyon_todo_flutter/src/settings/settings_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
@ -58,6 +60,22 @@ GoRouter buildTodoRouter() => GoRouter(
builder: (context, state) =>
ProjectScreen(projectId: state.pathParameters['id']!),
),
GoRoute(
path: 'filters',
builder: (context, state) => const FiltersScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) =>
FilterScreen(filterId: state.pathParameters['id']!),
),
],
),
GoRoute(
path: 'labels/:id',
builder: (context, state) =>
LabelScreen(labelId: state.pathParameters['id']!),
),
],
),
],

View file

@ -0,0 +1,34 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The schema of a filter's payload this app writes and reads.
const filterSchemaVersion = 1;
/// [content] as its row's payload, before sealing.
ByteData encodeFilter(FilterContent content) =>
encodeJson(filterSchemaVersion, {
'name': content.name,
'query': content.query,
if (content.description.isNotEmpty) 'description': content.description,
'color': ?content.color?.name,
if (content.favorite) 'favorite': true,
'position': content.position,
});
/// The filter [payload] holds, opened; null for one it can't read.
FilterContent? decodeFilter(ByteData payload) => decodeJson(
payload,
version: filterSchemaVersion,
name: 'Filter',
read: (json) => FilterContent(
name: json['name']! as String,
query: json['query']! as String,
description: json['description'] as String? ?? '',
color: PaletteColor.byName(json['color'] as String?),
favorite: json['favorite'] as bool? ?? false,
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);

View file

@ -0,0 +1,37 @@
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.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';
/// The person's filters (ADR 0033, D35), each sealed with their key for its
/// row (D5, [SealedRows]).
class FilterRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
late final _rows = SealedRows<FilterContent>(
_gateway,
_keys,
table: 'saved_filter',
encode: encodeFilter,
decode: decodeFilter,
);
/// Fires when the filters changed, here or on another device.
Stream<void> get changes => _rows.changes;
/// Every filter, by when it was made.
Future<List<StoredFilter>> all() async => [
for (final (:id, :content) in await _rows.all()) StoredFilter(id, content),
];
/// Keeps a new filter and returns its ID; throws [KeysMissing] where this
/// device can't seal.
Future<String> add(FilterContent content) => _rows.insert(content);
Future<void> update(String id, FilterContent content) =>
_rows.update(id, content);
Future<void> delete(String id) => _rows.delete([id]);
/// Closes what it holds open.
Future<void> dispose() => _rows.dispose();
}

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// The person's filters on their local database ([SealedRowGateway]), which the
/// sync engine keeps in step with their other devices.
class SyncFilterGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements SealedRowGateway {
@override
Future<List<SealedRow>> rows() async => [
for (final row in await protocol.SavedFilter.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<void> insert(SealedRow row) async {
await protocol.SavedFilter.db.insertRow(
_db,
protocol.SavedFilter(
id: protocol.UuidValue.withValidation(row.id),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.SavedFilter.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.SavedFilter.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(Iterable<String> ids) async {
final uuids = <protocol.UuidValue>{
for (final id in ids) protocol.UuidValue.withValidation(id),
};
if (uuids.isEmpty) {
return;
}
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
for (final row in await protocol.SavedFilter.db.find(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
)) {
await protocol.SavedFilter.db.updateRow(
_db,
protocol.SavedFilter(
id: row.id,
spaceId: row.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.SavedFilter.db.deleteWhere(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,295 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_search.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// What a filter's [FilterQuery] looks tasks up in: the day, and the names
/// tasks refer to by ID.
final class const FilterWorld({
required final Day today,
final Map<String, ({String name, String? parentId})> projects = const {},
final Map<String, String> sections = const {},
final Map<String, String> labels = const {},
});
/// What reading a filter's text came to.
sealed class const FilterParsed();
/// Read.
final class const FilterRead(final FilterQuery query) extends FilterParsed;
/// Not read: [near] is what it stopped at, empty where the text ended too
/// soon.
final class const FilterUnread(final String near) extends FilterParsed;
/// A filter's question to a task (ADR 0033, D35): words joined by `&` and
/// `|`, `&` binding closer, turned round by `!`, grouped by parentheses, as
/// Todoist writes them; English and German alike, whichever language the
/// person types, as a filter syncs to devices in another. Answered on the
/// device alone: the instance reads no task (D5). Done tasks are never
/// matched.
final class const FilterQuery._(final _Test _test) {
/// Reads [text].
static FilterParsed parse(String text) => _Reader(text).read();
/// Whether [task] answers, in [world].
bool matches(OpenTask task, FilterWorld world) =>
!task.content.completed && _test(task, world);
}
/// The tasks of [tasks] that [query] matches, those with a date first, the
/// nearest first, then by time, priority and place.
List<OpenTask> tasksOfFilter(
FilterQuery query,
Iterable<OpenTask> tasks,
FilterWorld world,
) =>
[
for (final task in tasks)
if (query.matches(task, world)) task,
]..sort((a, b) {
final (ad, bd) = (a.content.date?.day, b.content.date?.day);
if (ad != bd) {
if (ad == null) {
return 1;
}
if (bd == null) {
return -1;
}
return ad.compareTo(bd);
}
return byTime(a, b);
});
typedef _Test = bool Function(OpenTask task, FilterWorld world);
/// Words with no value, in both languages, folded as [folded] does.
final Map<String, _Test> _words = {
for (final MapEntry(key: names, :value) in _namedWords.entries)
for (final name in names) folded(name): value,
};
Day? _dayOf(OpenTask task) => task.content.date?.day;
final _namedWords = <List<String>, _Test>{
['today', 'heute']: (task, world) => _dayOf(task) == world.today,
['tomorrow', 'morgen']: (task, world) => _dayOf(task) == world.today.plus(1),
['yesterday', 'gestern']: (task, world) =>
_dayOf(task) == world.today.plus(-1),
['overdue', 'od', 'überfällig']: (task, world) => switch (_dayOf(task)) {
final day? => day < world.today,
null => false,
},
['no date', 'ohne datum']: (task, _) => task.content.date == null,
['no time', 'ohne zeit', 'ohne uhrzeit']: (task, _) =>
task.content.date != null && task.content.date!.time == null,
['recurring', 'wiederkehrend']: (task, _) =>
task.content.date?.repeat != null,
['deadline', 'frist']: (task, _) => task.content.deadline != null,
['no deadline', 'ohne deadline', 'ohne frist']: (task, _) =>
task.content.deadline == null,
['no labels', 'ohne labels']: (task, _) => task.content.labelIds.isEmpty,
['subtask', 'sub-task', 'unteraufgabe']: (task, _) =>
task.content.parentId != null,
['p1']: (task, _) => task.content.priority == Priority.p1,
['p2']: (task, _) => task.content.priority == Priority.p2,
['p3']: (task, _) => task.content.priority == Priority.p3,
['p4']: (task, _) => task.content.priority == Priority.none,
};
final _days = RegExp(r'^(?:next |nachste )?(\d{1,4}) (?:days?|tage?)$');
final _before = RegExp(r'^(?:before|vor):\s*(.+)$');
final _after = RegExp(r'^(?:after|nach):\s*(.+)$');
final _search = RegExp(r'^(?:search|suche):\s*(.+)$');
/// A name with `*` for anything, as a test of a folded name.
bool Function(String name) _namePattern(String pattern) {
final parts = folded(pattern).split('*').map(RegExp.escape).join('.*');
final expression = RegExp('^$parts\$', dotAll: true);
return (name) => expression.hasMatch(folded(name));
}
/// Reads a filter's text, by descent: `|` over `&` over `!` and parentheses
/// over a term, which runs up to the next operator as a name may hold
/// spaces.
class _Reader(final String _text) {
var _at = 0;
FilterParsed read() {
try {
final test = _or();
_skipSpaces();
if (_at < _text.length) {
throw _Stopped(_text.substring(_at).trim());
}
return FilterRead(FilterQuery._(test));
} on _Stopped catch (stopped) {
return FilterUnread(stopped.near);
}
}
void _skipSpaces() {
while (_at < _text.length && _text[_at].trim().isEmpty) {
_at++;
}
}
/// Takes [char] if it comes next.
bool _next(String char) {
_skipSpaces();
if (_at < _text.length && _text[_at] == char) {
_at++;
return true;
}
return false;
}
_Test _or() {
var left = _and();
while (_next('|')) {
final (first, second) = (left, _and());
left = (task, world) => first(task, world) || second(task, world);
}
return left;
}
_Test _and() {
var left = _not();
while (_next('&')) {
final (first, second) = (left, _not());
left = (task, world) => first(task, world) && second(task, world);
}
return left;
}
_Test _not() {
if (_next('!')) {
final test = _not();
return (task, world) => !test(task, world);
}
if (_next('(')) {
final test = _or();
if (!_next(')')) {
throw _Stopped(_text.substring(_at).trim());
}
return test;
}
return _term();
}
_Test _term() {
_skipSpaces();
final start = _at;
while (_at < _text.length && !'&|()'.contains(_text[_at])) {
_at++;
}
final term = _text.substring(start, _at).trim();
if (term.isEmpty) {
throw _Stopped(_text.substring(start).trim());
}
return _testOf(term) ?? (throw _Stopped(term));
}
_Test? _testOf(String term) {
if (term.startsWith('##')) {
return _within(term.substring(2).trim());
}
return switch (term[0]) {
'#' => _named(term.substring(1).trim(), (task, world) sync* {
if (world.projects[task.content.projectId]?.name case final name?) {
yield name;
}
}),
'/' => _named(term.substring(1).trim(), (task, world) sync* {
if (world.sections[task.content.sectionId] case final name?) {
yield name;
}
}),
'@' => _named(term.substring(1).trim(), (task, world) sync* {
for (final id in task.content.labelIds) {
if (world.labels[id] case final name?) {
yield name;
}
}
}),
_ => _wordOf(term),
};
}
/// The tasks in a project named [pattern], or below one.
_Test? _within(String pattern) {
if (pattern.isEmpty) {
return null;
}
final matches = _namePattern(pattern);
return (task, world) {
final seen = <String>{};
var id = task.content.projectId as String?;
while (id != null && seen.add(id)) {
final project = world.projects[id];
if (project != null && matches(project.name)) {
return true;
}
id = project?.parentId;
}
return false;
};
}
_Test? _named(
String pattern,
Iterable<String> Function(OpenTask task, FilterWorld world) names,
) {
if (pattern.isEmpty) {
return null;
}
final matches = _namePattern(pattern);
return (task, world) => names(task, world).any(matches);
}
_Test? _wordOf(String term) {
final word = folded(term).replaceAll(RegExp(r'\s+'), ' ');
if (_words[word] case final test?) {
return test;
}
if (_days.firstMatch(word) case final match?) {
final days = int.parse(match[1]!);
return days == 0
? null
: (task, world) => switch (_dayOf(task)) {
final day? =>
!(day < world.today) && day < world.today.plus(days),
null => false,
};
}
if (Day.tryParse(word) case final named?) {
return (task, _) => _dayOf(task) == named;
}
if (_before.firstMatch(word) case final match?) {
return switch (Day.tryParse(match[1]!)) {
final named? => (task, _) => switch (_dayOf(task)) {
final day? => day < named,
null => false,
},
null => null,
};
}
if (_after.firstMatch(word) case final match?) {
return switch (Day.tryParse(match[1]!)) {
final named? => (task, _) => switch (_dayOf(task)) {
final day? => day > named,
null => false,
},
null => null,
};
}
if (_search.firstMatch(word) case final match?) {
final wanted = match[1]!;
return (task, _) => folded(
'${task.content.title}\n${task.content.description}',
).contains(wanted);
}
return null;
}
}
final class const _Stopped(final String near) implements Exception;

View file

@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// A filter the person keeps (ADR 0033, D35): its [name], its [query] in
/// `FilterQuery`'s language, its [color] from halkyon_ui's palette (D9) and
/// its [position] among the others.
final class const FilterContent({
required final String name,
required final String query,
final String description = '',
final PaletteColor? color,
final bool favorite = false,
final double position = 0,
}) with Equatable {
FilterContent copyWith({
bool? favorite,
double? position,
}) => FilterContent(
name: name,
query: query,
description: description,
color: color,
favorite: favorite ?? this.favorite,
position: position ?? this.position,
);
@override
List<Object?> get props => [
name,
query,
description,
color,
favorite,
position,
];
}
/// A filter as kept: its [id], and [content] null where it doesn't open.
final class const StoredFilter(final String id, final FilterContent? content)
with Equatable {
@override
List<Object?> get props => [id, content];
}

View file

@ -0,0 +1,190 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The key of the editor's query field.
const filterQueryKey = ValueKey('filter-query');
/// The key of the editor's name field.
const filterNameKey = ValueKey('filter-name');
/// The key of the editor's saving button.
const filterSaveKey = ValueKey('filter-save');
/// Asks for a filter (ADR 0033, D35), [current]'s to change: its name, what
/// it asks, how many of [tasks] that finds as it is typed, or where it
/// wasn't understood. A screen on a phone, a dialog otherwise, as Todoist's.
/// Null where the person changed their mind.
Future<FilterContent?> editFilter(
BuildContext context, {
required List<OpenTask> tasks,
required FilterWorld world,
FilterContent? current,
}) {
final compact = !context.windowSize.isAtLeastMedium;
return showDialog<FilterContent>(
context: context,
builder: (context) {
final editor = _FilterEditor(
tasks: tasks,
world: world,
current: current,
);
return compact
? Dialog.fullscreen(child: editor)
: Dialog(
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 520,
maxHeight: 680,
),
child: editor,
),
);
},
);
}
class const _FilterEditor({
required final List<OpenTask> tasks,
required final FilterWorld world,
required final FilterContent? current,
}) extends StatefulWidget {
@override
State<_FilterEditor> createState() => _FilterEditorState();
}
class _FilterEditorState() extends State<_FilterEditor> {
late final _name = TextEditingController(text: widget.current?.name);
late final _description = TextEditingController(
text: widget.current?.description,
);
late final _query = TextEditingController(text: widget.current?.query);
late var _color = widget.current?.color ?? PaletteColor.cobalt;
late var _favorite = widget.current?.favorite ?? false;
@override
void dispose() {
_name.dispose();
_description.dispose();
_query.dispose();
super.dispose();
}
bool get _ready =>
_name.text.trim().isNotEmpty &&
FilterQuery.parse(_query.text) is FilterRead;
void _save() => Navigator.of(context).pop(
FilterContent(
name: _name.text.trim(),
query: _query.text.trim(),
description: _description.text.trim(),
color: _color,
favorite: _favorite,
position: widget.current?.position ?? 0,
),
);
Widget _preview(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
if (_query.text.trim().isEmpty) {
return const SizedBox.shrink();
}
return switch (FilterQuery.parse(_query.text)) {
FilterRead(:final query) => Text(
switch (tasksOfFilter(query, widget.tasks, widget.world).length) {
0 => l10n.filterPreviewEmpty,
final count => l10n.filterMatches(count),
},
style: TextStyle(color: scheme.onSurfaceVariant),
),
FilterUnread(:final near) => Text(
near.isEmpty ? l10n.filterNotReadEnd : l10n.filterNotRead(near),
style: TextStyle(color: scheme.error),
),
};
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: Text(widget.current == null ? l10n.filterAdd : l10n.filterEdit),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: FilledButton(
key: filterSaveKey,
onPressed: _ready ? _save : null,
child: Text(l10n.save),
),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
key: filterNameKey,
controller: _name,
autofocus: widget.current == null,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.filterName),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
TextField(
controller: _description,
minLines: 1,
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.filterDescription),
),
const SizedBox(height: 12),
TextField(
key: filterQueryKey,
controller: _query,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.filterQuery,
hintText: l10n.filterQueryHint,
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
_preview(context),
const SizedBox(height: 8),
Text(
l10n.filterHelp,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Text(l10n.colorLabel, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
PaletteColorPicker(
selected: _color,
onChanged: (picked) => setState(() => _color = picked),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.filterFavorite),
value: _favorite,
onChanged: (value) => setState(() => _favorite = value),
),
],
),
);
}
}

View file

@ -0,0 +1,270 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.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/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// What filters need of [state] to answer, today being as the device says.
FilterWorld filterWorldOf(TodoState state) => FilterWorld(
today: Day.of(DateTime.now()),
projects: {
for (final project in state.projects)
if (project.content case final content?)
project.id: (name: content.name, parentId: content.parentId),
},
sections: {
for (final section in state.sections) section.id: section.content!.name,
},
labels: {
for (final label in state.labels) label.id: label.content!.name,
},
);
/// The tasks of a filter or a label, each saying its project; [nothing]
/// where there are none, with [description] above them.
class const _TaskList({
required final List<OpenTask> tasks,
required final String nothing,
final String description = '',
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
return TaskRefresh(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
padding: EdgeInsets.fromLTRB(
12,
8,
12,
96 + FloatingNavigationBar.coverOf(context),
),
children: [
if (description.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: Text(description, style: TextStyle(color: muted)),
),
if (tasks.isEmpty)
Padding(
padding: const EdgeInsets.all(32),
child: Text(
nothing,
textAlign: TextAlign.center,
style: TextStyle(color: muted),
),
),
for (final task in tasks) TaskTile(task: task, showProject: true),
],
),
),
),
);
}
}
/// The tasks a filter finds (ADR 0033, D35), with a menu to edit it, favour
/// it or delete it.
class const FilterScreen({required final String filterId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final stored = state.filters
.where((filter) => filter.id == filterId)
.firstOrNull;
final content = stored?.content;
if (content == null) {
return TabScreen(title: l10n.filtersTitle, body: const SizedBox.shrink());
}
final todo = PersonScope.of<TodoPerson>(context);
final world = filterWorldOf(state);
final tasks = switch (FilterQuery.parse(content.query)) {
FilterRead(:final query) => tasksOfFilter(query, state.tasks, world),
FilterUnread() => const <OpenTask>[],
};
return TabScreen(
title: content.name,
actions: [
PopupMenuButton<VoidCallback>(
tooltip: l10n.filterMenu,
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () async {
final edited = await editFilter(
context,
tasks: state.tasks,
world: world,
current: content,
);
if (edited != null) {
await todo.filters.update(filterId, edited);
}
},
child: MenuEntry(LucideIcons.pencil, l10n.filterEdit),
),
PopupMenuItem(
value: () => todo.filters.update(
filterId,
content.copyWith(favorite: !content.favorite),
),
child: MenuEntry(
LucideIcons.heart,
content.favorite ? l10n.filterUnfavorite : l10n.filterFavorite,
),
),
PopupMenuItem(
value: () async {
if (await confirmDestructiveAction(
context,
title: l10n.filterDeleteTitle,
message: content.name,
confirmLabel: l10n.filterDelete,
)) {
await todo.filters.delete(filterId);
if (context.mounted) {
context.pop();
}
}
},
child: MenuEntry(LucideIcons.trash2, l10n.filterDelete),
),
],
),
],
floatingActionButton: AddTaskButton(
onPressed: () => showQuickAdd(context),
),
body: _TaskList(
tasks: tasks,
nothing: l10n.filterNothing,
description: content.description,
),
);
}
}
/// A label's open tasks (D35), with a menu to edit it, favour it or delete
/// it.
class const LabelScreen({required final String labelId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final LabelContent? content = state.labels
.where((label) => label.id == labelId)
.firstOrNull
?.content;
if (content == null) {
return TabScreen(title: l10n.labelsTitle, body: const SizedBox.shrink());
}
final todo = PersonScope.of<TodoPerson>(context);
final tasks = [
for (final task in state.tasks)
if (!task.content.completed && task.content.labelIds.contains(labelId))
task,
]..sort(byTime);
return TabScreen(
title: content.name,
actions: [
PopupMenuButton<VoidCallback>(
tooltip: l10n.labelMenu,
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () async {
final edited = await editLabel(context, current: content);
if (edited != null) {
await todo.labels.update(labelId, edited);
}
},
child: MenuEntry(LucideIcons.pencil, l10n.labelEditEntry),
),
PopupMenuItem(
value: () => todo.labels.update(
labelId,
content.favoured(favorite: !content.favorite),
),
child: MenuEntry(
LucideIcons.heart,
content.favorite ? l10n.filterUnfavorite : l10n.filterFavorite,
),
),
PopupMenuItem(
value: () async {
if (await confirmDestructiveAction(
context,
title: l10n.labelDeleteTitle,
message: l10n.labelDeleteMessage(content.name),
confirmLabel: l10n.labelDelete,
)) {
await todo.labels.delete(labelId);
if (context.mounted) {
context.pop();
}
}
},
child: MenuEntry(LucideIcons.trash2, l10n.labelDelete),
),
],
),
],
floatingActionButton: AddTaskButton(
onPressed: () => showQuickAdd(context),
),
body: _TaskList(tasks: tasks, nothing: l10n.labelNothing),
);
}
}
/// A filter as a row: its colour, its name, and opening it.
Widget filterRow(BuildContext context, StoredFilter filter) => ListTile(
leading: Icon(
LucideIcons.listFilter,
color: switch (filter.content!.color) {
final color? => paletteText(context, color),
null => null,
},
),
title: Text(filter.content!.name),
onTap: () => context.go(TodoPaths.filter(filter.id)),
);
/// A label as a row: its colour, its name, and opening it.
Widget labelRow(BuildContext context, StoredLabel label) => ListTile(
leading: Icon(
LucideIcons.tag,
color: switch (label.content!.color) {
final color? => paletteText(context, color),
null => null,
},
),
title: Text(label.content!.name),
onTap: () => context.go(TodoPaths.label(label.id)),
);

View file

@ -0,0 +1,130 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_screen.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The key of the button adding a filter.
const filterAddKey = ValueKey('filter-add');
/// The person's filters and labels (ADR 0033, D35), as Todoist keeps them:
/// a section of each, its rows opening their tasks.
class const FiltersScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final todo = PersonScope.of<TodoPerson>(context);
Widget heading(
String title, {
required String tip,
required VoidCallback onAdd,
Key? key,
}) => Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 0),
child: Row(
children: [
Expanded(child: Text(title, style: theme.textTheme.titleMedium)),
IconButton(
key: key,
tooltip: tip,
icon: const Icon(LucideIcons.plus),
onPressed: onAdd,
),
],
),
);
Widget none(String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
return TabScreen(
title: l10n.filtersLabelsTitle,
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
padding: EdgeInsets.fromLTRB(
12,
8,
12,
16 + FloatingNavigationBar.coverOf(context),
),
children: [
SectionCard(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(
l10n.filtersTitle,
key: filterAddKey,
tip: l10n.filterAdd,
onAdd: () async {
final added = await editFilter(
context,
tasks: state.tasks,
world: filterWorldOf(state),
);
if (added != null) {
await todo.filters.add(
added.copyWith(
position: state.filters.isEmpty
? 1
: state.filters.last.content!.position + 1,
),
);
}
},
),
if (state.filters.isEmpty) none(l10n.filtersNone),
for (final filter in state.filters)
filterRow(context, filter),
],
),
),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(
l10n.labelsTitle,
tip: l10n.labelAdd,
onAdd: () async {
final added = await editLabel(context);
if (added != null) {
await todo.labels.add(added);
}
},
),
if (state.labels.isEmpty) none(l10n.labelsNone),
for (final label in state.labels) labelRow(context, label),
],
),
),
],
),
),
),
);
}
}

View file

@ -12,6 +12,7 @@ ByteData encodeLabel(LabelContent content) => encodeJson(labelSchemaVersion, {
'name': content.name,
'color': ?content.color?.name,
'position': content.position,
if (content.favorite) 'favorite': true,
});
/// The label [payload] holds, opened; null for one it can't read.
@ -23,5 +24,6 @@ LabelContent? decodeLabel(ByteData payload) => decodeJson(
name: json['name']! as String,
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
favorite: json['favorite'] as bool? ?? false,
),
);

View file

@ -7,9 +7,19 @@ final class const LabelContent({
required final String name,
final PaletteColor? color,
final double position = 0,
/// Whether Browse lists it among the favourites (D35).
final bool favorite = false,
}) with Equatable {
LabelContent favoured({required bool favorite}) => LabelContent(
name: name,
color: color,
position: position,
favorite: favorite,
);
@override
List<Object?> get props => [name, color, position];
List<Object?> get props => [name, color, position, favorite];
}
/// A label as kept: its [id], and [content] null where it doesn't open.

View file

@ -22,6 +22,7 @@ Future<LabelContent?> editLabel(
name: name,
color: color,
position: current?.position ?? 0,
favorite: current?.favorite ?? false,
),
null => null,
};

View file

@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
@ -22,6 +23,9 @@ final class const TodoState({
final List<OpenTask> tasks = const [],
final List<StoredLabel> labels = const [],
/// Their filters that open, in their order (D35).
final List<StoredFilter> filters = const [],
/// Their settings of Todo's, as at first until read.
final TodoSettings settings = const TodoSettings(),
@ -58,6 +62,7 @@ final class const TodoState({
sections,
tasks,
labels,
filters,
settings,
locked,
];
@ -82,6 +87,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
_todo.sections.changes,
_todo.tasks.changes,
_todo.labels.changes,
_todo.filters.changes,
_todo.settings.changes,
]) {
_subscriptions.add(changes.listen((_) => unawaited(_reads.run())));
@ -94,6 +100,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
final sections = await _todo.sections.all();
final tasks = await _todo.tasks.all();
final labels = await _todo.labels.all();
final filters = await _todo.filters.all();
final settings = await _todo.settings.read();
if (isClosed) {
return;
@ -111,12 +118,17 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
for (final label in labels)
if (label.content != null) label,
],
filters: [
for (final filter in filters)
if (filter.content != null) filter,
]..sort((a, b) => a.content!.position.compareTo(b.content!.position)),
settings: settings,
locked: [
...projects.map((row) => row.content),
...sections.map((row) => row.content),
...tasks.map((row) => row.content),
...labels.map((row) => row.content),
...filters.map((row) => row.content),
].where((content) => content == null).length,
),
);

Some files were not shown because too many files have changed in this diff Show more