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
21 changed files with 21688 additions and 71 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
);
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ Future<LabelContent?> editLabel(
|
|||
name: name,
|
||||
color: color,
|
||||
position: current?.position ?? 0,
|
||||
favorite: current?.favorite ?? false,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue