feat(todo): dialogs on the desktop, the tabs reordered, a task's path and menu (ADR 0033, D11 to D14)
- halkyon_ui: showAdaptiveSheet shows a dialog on Linux, macOS and Windows at every width (bottom sheets stay on phones and tablets below expanded windows), so Learn and Train follow; SheetScaffold takes a heading in the title's place and may leave out its close button. - Todo: the tabs are Inbox, Today, Upcoming, Browse, Today still opening; `serverpod start` launches the app on Linux, as it has no web build. - A task's sheet shows where the task is (its project, an icon once tasks are above it, and the tasks above it, each opening what it names) and a ⋮ menu in place of its close button: when it was added (its UUID v7), Duplicate with its sub-tasks, hiding its done sub-tasks (synced in its payload), Close in the pane, Delete after asking. The description's icon stays by its first line. - A row names its project and section at the end of its date's line; every menu entry has its icon. - Learn: a studyset's path starts at the Library tab's icon, named by its tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
666e8dbcbb
commit
b908019969
22 changed files with 1050 additions and 125 deletions
|
|
@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
|
|||
|
||||
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`) and its reminders, minutes before its time (D5: `planReminders` plans them, `ReminderScheduler` under the person's app hands them to `ReminderGateway`, `SystemReminderGateway` on `flutter_local_notifications`, the system scheduling them but on Linux, where the running app shows them; `TodoPerson.dispose` cancels them; Android's manifest declares the boot receiver and exact alarms, the build desugars). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2; a dialog on the desktop, D11), where the task is on top (`_TaskPath`, its steps opening in place) and its ⋮ menu in place of a close button (when it was added, `addedAt` from its UUID v7; Duplicate, `TaskRepository.duplicate`; its done sub-tasks hidden, `hideDoneSubTasks`; Delete; D13), menus' entries with their icons (`MenuEntry`, D14), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
|
||||
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
||||
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart` on Linux, `device: linux`, as Todo has no web build); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.
|
||||
|
|
|
|||
|
|
@ -137,5 +137,13 @@
|
|||
"searchEmpty": "Finde eine Aufgabe über die Wörter in ihrem Titel, ihrer Beschreibung oder ihren Labels. Die Suche läuft auf diesem Gerät: deine Instanz kann deine Aufgaben nicht lesen.",
|
||||
"searchNone": "Nichts gefunden für „{query}“.",
|
||||
"searchProjects": "Projekte",
|
||||
"searchTasks": "Aufgaben"
|
||||
"searchTasks": "Aufgaben",
|
||||
"taskMore": "Optionen der Aufgabe",
|
||||
"taskAddedOn": "Hinzugefügt am {when}",
|
||||
"taskDuplicate": "Duplizieren",
|
||||
"taskDuplicated": "Aufgabe dupliziert",
|
||||
"subTasksHideDone": "Erledigte Unteraufgaben ausblenden",
|
||||
"subTasksShowDone": "Erledigte Unteraufgaben zeigen",
|
||||
"taskClose": "Schließen",
|
||||
"taskPath": "Wo die Aufgabe liegt"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,5 +167,14 @@
|
|||
"searchNone": "Nothing found for “{query}”.",
|
||||
"@searchNone": {"placeholders": {"query": {"type": "String"}}},
|
||||
"searchProjects": "Projects",
|
||||
"searchTasks": "Tasks"
|
||||
"searchTasks": "Tasks",
|
||||
"taskMore": "Task options",
|
||||
"taskAddedOn": "Added on {when}",
|
||||
"@taskAddedOn": {"placeholders": {"when": {"type": "String"}}},
|
||||
"taskDuplicate": "Duplicate",
|
||||
"taskDuplicated": "Task duplicated",
|
||||
"subTasksHideDone": "Hide done sub-tasks",
|
||||
"subTasksShowDone": "Show done sub-tasks",
|
||||
"taskClose": "Close",
|
||||
"taskPath": "Where the task is"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -891,6 +891,54 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Tasks'**
|
||||
String get searchTasks;
|
||||
|
||||
/// No description provided for @taskMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Task options'**
|
||||
String get taskMore;
|
||||
|
||||
/// No description provided for @taskAddedOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added on {when}'**
|
||||
String taskAddedOn(String when);
|
||||
|
||||
/// No description provided for @taskDuplicate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Duplicate'**
|
||||
String get taskDuplicate;
|
||||
|
||||
/// No description provided for @taskDuplicated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Task duplicated'**
|
||||
String get taskDuplicated;
|
||||
|
||||
/// No description provided for @subTasksHideDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide done sub-tasks'**
|
||||
String get subTasksHideDone;
|
||||
|
||||
/// No description provided for @subTasksShowDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show done sub-tasks'**
|
||||
String get subTasksShowDone;
|
||||
|
||||
/// No description provided for @taskClose.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close'**
|
||||
String get taskClose;
|
||||
|
||||
/// No description provided for @taskPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Where the task is'**
|
||||
String get taskPath;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -552,4 +552,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchTasks => 'Aufgaben';
|
||||
|
||||
@override
|
||||
String get taskMore => 'Optionen der Aufgabe';
|
||||
|
||||
@override
|
||||
String taskAddedOn(String when) {
|
||||
return 'Hinzugefügt am $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get taskDuplicate => 'Duplizieren';
|
||||
|
||||
@override
|
||||
String get taskDuplicated => 'Aufgabe dupliziert';
|
||||
|
||||
@override
|
||||
String get subTasksHideDone => 'Erledigte Unteraufgaben ausblenden';
|
||||
|
||||
@override
|
||||
String get subTasksShowDone => 'Erledigte Unteraufgaben zeigen';
|
||||
|
||||
@override
|
||||
String get taskClose => 'Schließen';
|
||||
|
||||
@override
|
||||
String get taskPath => 'Wo die Aufgabe liegt';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -552,6 +552,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchTasks => 'Tasks';
|
||||
|
||||
@override
|
||||
String get taskMore => 'Task options';
|
||||
|
||||
@override
|
||||
String taskAddedOn(String when) {
|
||||
return 'Added on $when';
|
||||
}
|
||||
|
||||
@override
|
||||
String get taskDuplicate => 'Duplicate';
|
||||
|
||||
@override
|
||||
String get taskDuplicated => 'Task duplicated';
|
||||
|
||||
@override
|
||||
String get subTasksHideDone => 'Hide done sub-tasks';
|
||||
|
||||
@override
|
||||
String get subTasksShowDone => 'Show done sub-tasks';
|
||||
|
||||
@override
|
||||
String get taskClose => 'Close';
|
||||
|
||||
@override
|
||||
String get taskPath => 'Where the task is';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -19,8 +19,17 @@ GoRouter buildTodoRouter() => GoRouter(
|
|||
builder: (context, state, shell) => TodoShell(shell: shell),
|
||||
navigatorContainerBuilder: (context, shell, tabs) =>
|
||||
TabsStack(index: shell.currentIndex, children: tabs),
|
||||
// In the order of the shell's destinations.
|
||||
// In the order of the shell's destinations (the developer's, 1 Nov:
|
||||
// Inbox first, though Today, at `/`, opens).
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: TodoPaths.inbox,
|
||||
builder: (context, state) => const InboxTab(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
|
@ -37,14 +46,6 @@ GoRouter buildTodoRouter() => GoRouter(
|
|||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: TodoPaths.inbox,
|
||||
builder: (context, state) => const InboxTab(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What a menu's entry shows (the developer's, 1 Nov 2026): its [icon]
|
||||
/// leading its [label], as Todoist's and Learn's menus have them; a
|
||||
/// [destructive] one, as deleting, in the error colour.
|
||||
class const MenuEntry(
|
||||
final IconData icon,
|
||||
final String label, {
|
||||
final bool destructive = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = destructive ? Theme.of(context).colorScheme.error : null;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: color == null ? null : TextStyle(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ 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/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -41,13 +42,14 @@ class _ProjectScreenBodyState() extends State<ProjectScreenBody> {
|
|||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => setState(() => _completed = !_completed),
|
||||
child: Text(
|
||||
child: MenuEntry(
|
||||
_completed ? LucideIcons.eyeOff : LucideIcons.circleCheck,
|
||||
_completed ? l10n.completedHide : l10n.completedShow,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => addSection(context, projectId),
|
||||
child: Text(l10n.sectionAdd),
|
||||
child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd),
|
||||
),
|
||||
...?widget.menu?.call(context),
|
||||
],
|
||||
|
|
@ -116,7 +118,7 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
await todo.projects.update(projectId, edited);
|
||||
}
|
||||
},
|
||||
child: Text(l10n.projectEdit),
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.projectEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
|
|
@ -132,7 +134,11 @@ class const ProjectScreen({required final String projectId, super.key})
|
|||
router.pop();
|
||||
}
|
||||
},
|
||||
child: Text(l10n.projectDelete),
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.projectDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -110,15 +111,19 @@ class const ProjectTasks({
|
|||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.add,
|
||||
child: Text(l10n.taskAddTitle),
|
||||
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.edit,
|
||||
child: Text(l10n.sectionEdit),
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _SectionAction.delete,
|
||||
child: Text(l10n.sectionDelete),
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.sectionDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
|||
'completions': [
|
||||
for (final at in content.completions) at.toUtc().toIso8601String(),
|
||||
],
|
||||
if (content.hideDoneSubTasks) 'hideDoneSubTasks': true,
|
||||
});
|
||||
|
||||
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
|
||||
|
|
@ -93,6 +94,7 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
|
|||
for (final at in json['completions'] as List<Object?>? ?? const [])
|
||||
DateTime.parse(at! as String).toLocal(),
|
||||
],
|
||||
hideDoneSubTasks: json['hideDoneSubTasks'] as bool? ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
|
|
@ -91,6 +93,43 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Copies the task [id] with its sub-tasks, theirs too, each as it is,
|
||||
/// the copy right after it among its siblings, and returns the copy's ID
|
||||
/// (the developer's, 1 Nov: the task's menu's Duplicate, as Todoist's).
|
||||
Future<String> duplicate(String id) async {
|
||||
final tasks = await all();
|
||||
final contents = {for (final task in tasks) task.id: ?task.content};
|
||||
final original = contents[id];
|
||||
if (original == null) {
|
||||
throw StateError('No task $id here');
|
||||
}
|
||||
final after = [
|
||||
for (final content in contents.values)
|
||||
if (content.parentId == original.parentId &&
|
||||
content.projectId == original.projectId &&
|
||||
content.sectionId == original.sectionId &&
|
||||
content.position > original.position)
|
||||
content.position,
|
||||
];
|
||||
final position = after.isEmpty
|
||||
? original.position + 1
|
||||
: (original.position + after.reduce(min)) / 2;
|
||||
final copy = await add(original.copyWith(position: position));
|
||||
Future<void> copyBelow(String from, String to) async {
|
||||
for (final entry in contents.entries.toList()) {
|
||||
if (entry.value.parentId == from) {
|
||||
final childCopy = await add(
|
||||
entry.value.copyWith(parentId: () => to),
|
||||
);
|
||||
await copyBelow(entry.key, childCopy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await copyBelow(id, copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// Deletes the task [id] with its sub-tasks, theirs too, together.
|
||||
Future<void> delete(String id) async {
|
||||
final tasks = await all();
|
||||
|
|
|
|||
|
|
@ -149,6 +149,11 @@ final class const TaskContent({
|
|||
/// When a recurring task's past times were done, the oldest first (D6),
|
||||
/// for Reporting to count.
|
||||
final List<DateTime> completions = const [],
|
||||
|
||||
/// Whether its sheet leaves out its sub-tasks that are done (the
|
||||
/// developer's, 1 Nov: its menu's Hide completed sub-tasks, as Todoist
|
||||
/// keeps it per task, on every device).
|
||||
final bool hideDoneSubTasks = false,
|
||||
}) with Equatable {
|
||||
/// Whether it is done.
|
||||
bool get completed => completedAt != null;
|
||||
|
|
@ -167,6 +172,7 @@ final class const TaskContent({
|
|||
List<int>? reminders,
|
||||
DateTime? Function()? completedAt,
|
||||
List<DateTime>? completions,
|
||||
bool? hideDoneSubTasks,
|
||||
}) => TaskContent(
|
||||
title: title ?? this.title,
|
||||
projectId: projectId ?? this.projectId,
|
||||
|
|
@ -181,6 +187,7 @@ final class const TaskContent({
|
|||
reminders: reminders ?? this.reminders,
|
||||
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
||||
completions: completions ?? this.completions,
|
||||
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -198,6 +205,7 @@ final class const TaskContent({
|
|||
reminders,
|
||||
completedAt,
|
||||
completions,
|
||||
hideDoneSubTasks,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,3 +118,17 @@ int openCountIn(List<OpenTask> tasks, String projectId) => tasks
|
|||
(task) => task.content.projectId == projectId && !task.content.completed,
|
||||
)
|
||||
.length;
|
||||
|
||||
/// When the task [id] was added, as its UUID v7 says (its first 48 bits,
|
||||
/// milliseconds since 1970), in local time; null for an ID of another kind.
|
||||
/// The instance knows as much from the row, so the payload needn't say it.
|
||||
DateTime? addedAt(String id) {
|
||||
final hex = id.replaceAll('-', '');
|
||||
if (hex.length != 32 || hex[12] != '7') {
|
||||
return null;
|
||||
}
|
||||
return switch (int.tryParse(hex.substring(0, 12), radix: 16)) {
|
||||
final ms? => DateTime.fromMillisecondsSinceEpoch(ms),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ import 'dart:async';
|
|||
|
||||
import 'package:clock/clock.dart';
|
||||
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/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
|
|
@ -21,11 +25,14 @@ import 'package:halkyon_ui/halkyon_ui.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens the task [id]'s sheet (ADR 0033, D8): its title and description to
|
||||
/// edit, a row for each of its date, deadline, priority, project and labels,
|
||||
/// its sub-tasks with one more to add, and deleting it, which asks first.
|
||||
/// From expanded windows on, where a [TaskPane] is above, it opens in the
|
||||
/// pane beside the list instead (D2).
|
||||
/// Opens the task [id]'s sheet (ADR 0033, D8): where it is on top (its
|
||||
/// project, an icon once tasks are above it, and the tasks above it, each
|
||||
/// opening what it names), its menu (when it was added, Duplicate, hiding
|
||||
/// its done sub-tasks, deleting it after asking), its title and
|
||||
/// description to edit, a row for each of its date, deadline, priority,
|
||||
/// project and labels, and its sub-tasks with one more to add. From
|
||||
/// expanded windows on, where a [TaskPane] is above, it opens in the pane
|
||||
/// beside the list instead (D2).
|
||||
Future<void> openTaskSheet(BuildContext context, String id) async {
|
||||
if (context.windowSize.isAtLeastExpanded) {
|
||||
if (TaskPane.maybeOf(context) case final pane?) {
|
||||
|
|
@ -35,7 +42,24 @@ Future<void> openTaskSheet(BuildContext context, String id) async {
|
|||
}
|
||||
await showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _TaskSheet(id: id),
|
||||
builder: (context) => _TaskSheetHost(id: id),
|
||||
);
|
||||
}
|
||||
|
||||
/// A sheet's task, which a step of its path replaces in place.
|
||||
class const _TaskSheetHost({required final String id}) extends StatefulWidget {
|
||||
@override
|
||||
State<_TaskSheetHost> createState() => _TaskSheetHostState();
|
||||
}
|
||||
|
||||
class _TaskSheetHostState() extends State<_TaskSheetHost> {
|
||||
late var _id = widget.id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _TaskSheet(
|
||||
key: ValueKey(_id),
|
||||
id: _id,
|
||||
onOpen: (id) => setState(() => _id = id),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +124,9 @@ class const _TaskPaneScope({
|
|||
class const _TaskSheet({
|
||||
required final String id,
|
||||
final bool inPane = false,
|
||||
|
||||
/// Opens another task in the sheet's place; in the pane, the pane does.
|
||||
final ValueChanged<String>? onOpen,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -194,6 +221,15 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Opens the task [id] in this one's place.
|
||||
void _open(BuildContext context, String id) {
|
||||
if (widget.inPane) {
|
||||
TaskPane.maybeOf(context)?.value = id;
|
||||
} else {
|
||||
widget.onOpen?.call(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// What closes it: the pane's close, or the sheet's route.
|
||||
VoidCallback _closer(BuildContext context) {
|
||||
if (widget.inPane) {
|
||||
|
|
@ -204,17 +240,21 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
return sheet.pop;
|
||||
}
|
||||
|
||||
/// Its frame: the sheet's, or the pane's, its title, action and close
|
||||
/// button on top and the rest scrolling below.
|
||||
/// Its frame: the sheet's, or the pane's, its [heading] (else its
|
||||
/// title) and [action] on top and the rest scrolling below. With an
|
||||
/// action, the action's menu closes it, not a close button of its own.
|
||||
Widget _frame(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required Widget body,
|
||||
Widget? heading,
|
||||
Widget? action,
|
||||
}) {
|
||||
if (!widget.inPane) {
|
||||
return SheetScaffold(
|
||||
title: title,
|
||||
heading: heading,
|
||||
closeButton: action == null,
|
||||
hasSnackBars: true,
|
||||
action: action,
|
||||
body: body,
|
||||
|
|
@ -228,18 +268,21 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child:
|
||||
heading ??
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?action,
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: _closer(context),
|
||||
),
|
||||
if (action == null)
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: _closer(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -253,6 +296,99 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
);
|
||||
}
|
||||
|
||||
/// The task's menu (the developer's, 1 Nov, in place of the close
|
||||
/// button): when it was added, Duplicate, hiding or showing its done
|
||||
/// sub-tasks, Close in the pane, and deleting it, which asks first.
|
||||
Widget _menu(BuildContext context, OpenTask task, List<OpenTask> subTasks) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final content = task.content;
|
||||
final added = addedAt(task.id);
|
||||
final material = MaterialLocalizations.of(context);
|
||||
return PopupMenuButton<Future<void> Function()>(
|
||||
tooltip: l10n.taskMore,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onSelected: (action) => unawaited(action()),
|
||||
itemBuilder: (context) => [
|
||||
if (added != null) ...[
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: Text(
|
||||
l10n.taskAddedOn(
|
||||
'${material.formatMediumDate(added)} · '
|
||||
'${material.formatTimeOfDay(
|
||||
TimeOfDay.fromDateTime(added),
|
||||
alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
|
||||
)}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
],
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await _tasks.duplicate(task.id);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.taskDuplicated)),
|
||||
);
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.keysMissing)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: MenuEntry(LucideIcons.copyPlus, l10n.taskDuplicate),
|
||||
),
|
||||
if (subTasks.any((sub) => sub.content.completed) ||
|
||||
content.hideDoneSubTasks)
|
||||
PopupMenuItem(
|
||||
value: () => _change(
|
||||
(c) => c.copyWith(hideDoneSubTasks: !c.hideDoneSubTasks),
|
||||
),
|
||||
child: MenuEntry(
|
||||
content.hideDoneSubTasks
|
||||
? LucideIcons.circleCheck
|
||||
: LucideIcons.eyeOff,
|
||||
content.hideDoneSubTasks
|
||||
? l10n.subTasksShowDone
|
||||
: l10n.subTasksHideDone,
|
||||
),
|
||||
),
|
||||
if (widget.inPane)
|
||||
PopupMenuItem(
|
||||
value: () async => _closer(context)(),
|
||||
child: MenuEntry(LucideIcons.x, l10n.taskClose),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
final close = _closer(context);
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.taskDeleteTitle,
|
||||
message: subTasks.isEmpty
|
||||
? l10n.taskDeleteMessage(content.title)
|
||||
: l10n.taskDeleteWithSubTasks(
|
||||
content.title,
|
||||
subTasks.length,
|
||||
),
|
||||
confirmLabel: l10n.taskDelete,
|
||||
)) {
|
||||
await _tasks.delete(task.id);
|
||||
close();
|
||||
}
|
||||
},
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.taskDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -282,24 +418,20 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
final project? when !project.inbox => project.content!.name,
|
||||
_ => l10n.shellTabInbox,
|
||||
},
|
||||
action: IconButton(
|
||||
tooltip: l10n.taskDelete,
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () async {
|
||||
final close = _closer(context);
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.taskDeleteTitle,
|
||||
message: subTasks.isEmpty
|
||||
? l10n.taskDeleteMessage(content.title)
|
||||
: l10n.taskDeleteWithSubTasks(content.title, subTasks.length),
|
||||
confirmLabel: l10n.taskDelete,
|
||||
)) {
|
||||
await _tasks.delete(widget.id);
|
||||
close();
|
||||
heading: _TaskPath(
|
||||
task: task,
|
||||
onOpen: (id) => _open(context, id),
|
||||
onProject: (project) {
|
||||
final router = GoRouter.of(context);
|
||||
if (!widget.inPane) {
|
||||
_closer(context)();
|
||||
}
|
||||
router.go(
|
||||
project.inbox ? TodoPaths.inbox : TodoPaths.project(project.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
action: _menu(context, task, subTasks),
|
||||
body: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -322,17 +454,29 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _description,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(LucideIcons.text),
|
||||
hintText: l10n.taskDescriptionHint,
|
||||
),
|
||||
onTapOutside: (_) => unawaited(_saveText()),
|
||||
// The icon stays by the first line however long the
|
||||
// description grows (the developer's, 1 Nov).
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: Icon(LucideIcons.text),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _description,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: l10n.taskDescriptionHint,
|
||||
),
|
||||
onTapOutside: (_) => unawaited(_saveText()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
|
|
@ -469,7 +613,9 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
style: theme.textTheme.titleSmall?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
for (final sub in subTasks) TaskTile(task: sub),
|
||||
for (final sub in subTasks)
|
||||
if (!(content.hideDoneSubTasks && sub.content.completed))
|
||||
TaskTile(task: sub),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
|
|
@ -488,3 +634,101 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of the path atop a task's sheet, for tests to find it by.
|
||||
const taskPathKey = ValueKey('task-path');
|
||||
|
||||
/// Where [task] is (the developer's, 1 Nov, as Learn's path through a
|
||||
/// studyset): its project, named while it is a task of its own and an icon
|
||||
/// once tasks are above it, then each task above it, the nearest last; a
|
||||
/// tap on the project goes to its list ([onProject]), on a task opens it
|
||||
/// in the sheet's place ([onOpen]).
|
||||
class const _TaskPath({
|
||||
required final OpenTask task,
|
||||
required final ValueChanged<String> onOpen,
|
||||
required final ValueChanged<StoredProject> onProject,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final above = <OpenTask>[];
|
||||
final seen = {task.id};
|
||||
var parent = task.content.parentId;
|
||||
while (parent != null && seen.add(parent)) {
|
||||
final next = state.taskOf(parent);
|
||||
if (next == null) {
|
||||
break;
|
||||
}
|
||||
above.insert(0, next);
|
||||
parent = next.content.parentId;
|
||||
}
|
||||
final project = state.projectOf(task.content.projectId);
|
||||
final style = theme.textTheme.titleSmall;
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
Widget step({required Widget child, required VoidCallback onTap}) =>
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
const chevron = Icon(LucideIcons.chevronRight, size: 16);
|
||||
return Semantics(
|
||||
key: taskPathKey,
|
||||
label: l10n.taskPath,
|
||||
container: true,
|
||||
child: Row(
|
||||
children: [
|
||||
if (project != null)
|
||||
step(
|
||||
onTap: () => onProject(project),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
|
||||
size: 18,
|
||||
color: switch (project.content?.color) {
|
||||
final color? => paletteText(context, color),
|
||||
null => muted,
|
||||
},
|
||||
),
|
||||
if (above.isEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
project.inbox
|
||||
? l10n.shellTabInbox
|
||||
: project.content!.name,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final step_ in above) ...[
|
||||
chevron,
|
||||
Flexible(
|
||||
child: step(
|
||||
onTap: () => onOpen(step_.id),
|
||||
child: Text(
|
||||
step_.content.title,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// its title (struck through once done), the first line of its
|
||||
/// description, and what it has: its date in the colour of how near it is,
|
||||
/// its deadline, how many of its sub-tasks are done, its labels; where
|
||||
/// [showProject], the project it is in. A tap opens its sheet; the check
|
||||
/// [showProject], the project and section it is in at the end of that line. A tap opens its sheet; the check
|
||||
/// marks it done, which can be undone for a moment.
|
||||
class const TaskTile({
|
||||
required final OpenTask task,
|
||||
|
|
@ -39,12 +39,27 @@ class const TaskTile({
|
|||
for (final label in state.labels) label.id: label.content!,
|
||||
};
|
||||
final project = state.projectOf(content.projectId);
|
||||
final section = switch (content.sectionId) {
|
||||
final id? =>
|
||||
state.sections
|
||||
.where((section) => section.id == id)
|
||||
.firstOrNull
|
||||
?.content,
|
||||
null => null,
|
||||
};
|
||||
Widget meta(IconData icon, String text, Color color) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(text, style: small?.copyWith(color: color)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: small?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final details = [
|
||||
|
|
@ -74,6 +89,38 @@ class const TaskTile({
|
|||
},
|
||||
),
|
||||
];
|
||||
final where = showProject && project != null
|
||||
? Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
[
|
||||
if (project.inbox)
|
||||
l10n.shellTabInbox
|
||||
else
|
||||
project.content!.name,
|
||||
?section?.name,
|
||||
].join(' / '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: small?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
|
||||
size: 14,
|
||||
color: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 4, right: 16),
|
||||
leading: TaskCheck(task: task),
|
||||
|
|
@ -86,7 +133,7 @@ class const TaskTile({
|
|||
)
|
||||
: null,
|
||||
),
|
||||
subtitle: details.isEmpty && content.description.isEmpty
|
||||
subtitle: details.isEmpty && content.description.isEmpty && where == null
|
||||
? null
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -97,19 +144,27 @@ class const TaskTile({
|
|||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (details.isNotEmpty)
|
||||
if (details.isNotEmpty || where != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Wrap(spacing: 12, runSpacing: 2, children: details),
|
||||
// Where the task is ends the line of its date, as its
|
||||
// place, not a detail of it (the developer's, 1 Nov).
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 2,
|
||||
children: details,
|
||||
),
|
||||
),
|
||||
?where,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: showProject && project != null
|
||||
? Text(
|
||||
project.inbox ? l10n.shellTabInbox : project.content!.name,
|
||||
style: small?.copyWith(color: muted),
|
||||
)
|
||||
: null,
|
||||
onTap: () => openTaskSheet(context, task.id),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The frame around Todo's tabs (ADR 0033, D1, D2): halkyon_ui's
|
||||
/// [NavigationFrame], as every Halkyon app's, with Today, Upcoming, Inbox
|
||||
/// and Browse, a bar floating above the foot below medium windows and a rail
|
||||
/// [NavigationFrame], as every Halkyon app's, with Inbox, Today, Upcoming
|
||||
/// and Browse (Today opening), a bar floating above the foot below medium windows and a rail
|
||||
/// with Todo's icon on top from medium on. Every tab keeps its own stack of
|
||||
/// screens. After an update, what's new shows once (halkyon_app's
|
||||
/// [WhatsNewOnUpdate]), where the app is given it.
|
||||
|
|
@ -32,6 +32,11 @@ class const TodoShell({
|
|||
initialLocation: index == shell.currentIndex,
|
||||
),
|
||||
destinations: [
|
||||
(
|
||||
label: l10n.shellTabInbox,
|
||||
icon: const Icon(LucideIcons.inbox),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
label: l10n.shellTabToday,
|
||||
icon: const Icon(LucideIcons.calendarCheck),
|
||||
|
|
@ -42,11 +47,6 @@ class const TodoShell({
|
|||
icon: const Icon(LucideIcons.calendarDays),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
label: l10n.shellTabInbox,
|
||||
icon: const Icon(LucideIcons.inbox),
|
||||
selectedIcon: null,
|
||||
),
|
||||
(
|
||||
label: l10n.shellTabBrowse,
|
||||
// The cog to Settings is atop Browse: the count of accounts
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
|
@ -7,9 +8,11 @@ import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -166,6 +169,13 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Closes the task's sheet as a tap beside it does: it has no close
|
||||
/// button, its menu in the place of one.
|
||||
Future<void> closeTaskSheet(WidgetTester tester) async {
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens Settings from Browse's cog (ADR 0033, D2).
|
||||
Future<void> openSettings(WidgetTester tester) async {
|
||||
await goTab(tester, 'Browse');
|
||||
|
|
@ -208,15 +218,15 @@ void main() {
|
|||
});
|
||||
|
||||
group("Todo's frame (ADR 0033, D1 to D4)", () {
|
||||
testWidgets('a phone has Today, Upcoming, Inbox and Browse, and opens on '
|
||||
testWidgets('a phone has Inbox, Today, Upcoming and Browse, and opens on '
|
||||
'Today', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
|
||||
expect(opened.single, FakeAuthGateway.person);
|
||||
expect(tabs(tester), ['Today', 'Upcoming', 'Inbox', 'Browse']);
|
||||
expect(tabs(tester), ['Inbox', 'Today', 'Upcoming', 'Browse']);
|
||||
expect(
|
||||
tester.widget<NavigationBar>(find.byType(NavigationBar)).selectedIndex,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
expect(find.widgetWithText(AppBar, 'Today'), findsOneWidget);
|
||||
});
|
||||
|
|
@ -413,7 +423,7 @@ void main() {
|
|||
await tester.tap(find.text('Every day'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Every day'), findsOneWidget, reason: 'in the sheet');
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await closeTaskSheet(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
|
|
@ -487,7 +497,7 @@ void main() {
|
|||
await tester.tap(find.text('Undated'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No reminders'), findsNothing, reason: 'no time');
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await closeTaskSheet(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Call the bank'));
|
||||
|
|
@ -515,6 +525,43 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets("Today names a task's project and section on the line of "
|
||||
'its date, at its end', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
await start(tester, usedBefore: true);
|
||||
final section = await tester.runAsync(
|
||||
() => SectionRepository(
|
||||
sectionRows,
|
||||
keys,
|
||||
).add(SectionContent(name: 'Errands', projectId: inbox())),
|
||||
);
|
||||
sectionRows.bringRemoteChanges();
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Feed the dogs',
|
||||
projectId: inbox(),
|
||||
sectionId: section,
|
||||
date: TaskDate(today, time: 6 * 60),
|
||||
),
|
||||
);
|
||||
|
||||
final where = find.text('Inbox / Errands');
|
||||
expect(where, findsOneWidget);
|
||||
final date = find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'Feed the dogs'),
|
||||
matching: find.textContaining('06:00'),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(where).dy,
|
||||
moreOrLessEquals(tester.getCenter(date).dy, epsilon: 2),
|
||||
);
|
||||
expect(
|
||||
tester.getTopRight(where).dx,
|
||||
greaterThan(tester.getTopRight(date).dx),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Today puts what is overdue first; Reschedule moves it to '
|
||||
'the day picked', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
|
|
@ -612,7 +659,9 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sub-tasks 0/1'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Delete task'));
|
||||
await tester.tap(find.byTooltip('Task options'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete task'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('"Pack" is deleted with its sub-task, on all your devices.'),
|
||||
|
|
@ -625,6 +674,128 @@ void main() {
|
|||
expect(await tester.runAsync(() => tasks().all()), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("a task's sheet shows where it is, the project as an icon "
|
||||
'once tasks are above it, each step opening what it names', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
final list = await tester.runAsync(
|
||||
() => tasks().add(TaskContent(title: 'Shopping', projectId: inbox())),
|
||||
);
|
||||
final dairy = await tester.runAsync(
|
||||
() => tasks().add(
|
||||
TaskContent(title: 'Dairy', projectId: inbox(), parentId: list),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(title: 'Butter', projectId: inbox(), parentId: dairy),
|
||||
);
|
||||
await goTab(tester, 'Inbox');
|
||||
|
||||
await tester.tap(find.text('Shopping').first);
|
||||
await tester.pumpAndSettle();
|
||||
final path = find.byKey(taskPathKey);
|
||||
expect(
|
||||
find.descendant(of: path, matching: find.text('Inbox')),
|
||||
findsOneWidget,
|
||||
reason: 'a task of its own names its project',
|
||||
);
|
||||
await tester.tap(find.text('Dairy').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Butter').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.descendant(of: path.last, matching: find.text('Inbox')),
|
||||
findsNothing,
|
||||
reason: 'the project an icon once tasks are above',
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: path.last,
|
||||
matching: find.byIcon(LucideIcons.inbox),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: path.last, matching: find.text('Shopping')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(
|
||||
find.descendant(of: path.last, matching: find.text('Dairy')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.widgetWithText(TextField, 'Dairy'),
|
||||
findsWidgets,
|
||||
reason: 'the step opened in the sheet',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("a task's menu says when it was added, duplicates it with its "
|
||||
'sub-tasks and hides its done ones, on every device', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
final pack = await tester.runAsync(
|
||||
() => tasks().add(TaskContent(title: 'Pack', projectId: inbox())),
|
||||
);
|
||||
await tester.runAsync(
|
||||
() => tasks().add(
|
||||
TaskContent(title: 'Socks', projectId: inbox(), parentId: pack),
|
||||
),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Passport',
|
||||
projectId: inbox(),
|
||||
parentId: pack,
|
||||
completedAt: DateTime(2026, 10, 30),
|
||||
),
|
||||
);
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Pack'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Passport'), findsOneWidget);
|
||||
expect(find.byTooltip('Close'), findsNothing, reason: 'the menu instead');
|
||||
|
||||
await tester.tap(find.byTooltip('Task options'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Added on '), findsOneWidget);
|
||||
await tester.tap(find.text('Hide done sub-tasks'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Passport'), findsNothing);
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
expect(
|
||||
stored!.firstWhere((t) => t.id == pack).content!.hideDoneSubTasks,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Task options'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Duplicate'));
|
||||
await tester.pumpAndSettle();
|
||||
final after = await tester.runAsync(() => tasks().all());
|
||||
expect(after!.where((t) => t.content?.title == 'Pack'), hasLength(2));
|
||||
expect(after.where((t) => t.content?.title == 'Socks'), hasLength(2));
|
||||
});
|
||||
|
||||
testWidgets('on the desktop a task opens in a dialog, however narrow '
|
||||
'the window', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(tester, TaskContent(title: 'Pack', projectId: inbox()));
|
||||
await goTab(tester, 'Inbox');
|
||||
|
||||
await tester.tap(find.text('Pack'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('Upcoming lists the days ahead, the empty ones too', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -684,7 +855,7 @@ void main() {
|
|||
await tester.tap(find.text('Call Anna'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TextField, 'Call Anna'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await closeTaskSheet(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pageBack();
|
||||
|
|
@ -724,7 +895,9 @@ void main() {
|
|||
reason: 'what was typed is kept as another opens',
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.tap(find.byTooltip('Task options'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(VerticalDivider), findsNothing);
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ void main() {
|
|||
reminders: const [30, 0],
|
||||
completedAt: DateTime(2026, 11, 2, 7),
|
||||
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
|
||||
hideDoneSubTasks: true,
|
||||
);
|
||||
|
||||
group('a task', () {
|
||||
|
|
@ -84,6 +85,7 @@ void main() {
|
|||
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
|
||||
DateTime(2026, 10, 22, 6).toUtc().toIso8601String(),
|
||||
],
|
||||
'hideDoneSubTasks': true,
|
||||
});
|
||||
expect(
|
||||
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
|
||||
|
|
|
|||
|
|
@ -177,6 +177,45 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test('duplicating a task copies it with its sub-tasks, theirs too, right '
|
||||
'after it among its siblings', () async {
|
||||
final tasks = await device(alice);
|
||||
final first = await tasks.add(milk.copyWith(position: 1));
|
||||
await tasks.add(milk.copyWith(title: 'Next', position: 2));
|
||||
final sub = await tasks.add(
|
||||
const TaskContent(
|
||||
title: 'Oat',
|
||||
projectId: 'inbox',
|
||||
position: 1,
|
||||
).copyWith(parentId: () => first),
|
||||
);
|
||||
await tasks.add(
|
||||
TaskContent(
|
||||
title: 'Bottles',
|
||||
projectId: 'inbox',
|
||||
parentId: sub,
|
||||
completedAt: DateTime(2026, 11, 2),
|
||||
),
|
||||
);
|
||||
|
||||
final copy = await tasks.duplicate(first);
|
||||
|
||||
final all = {for (final t in await tasks.all()) t.id: t.content!};
|
||||
expect(all, hasLength(7));
|
||||
expect(all[copy]!.title, 'Buy milk');
|
||||
expect(all[copy]!.position, 1.5, reason: 'between it and the next');
|
||||
final copiedSub = all.entries.singleWhere(
|
||||
(e) => e.value.parentId == copy,
|
||||
);
|
||||
expect(copiedSub.value.title, 'Oat');
|
||||
expect(copiedSub.key, isNot(sub));
|
||||
final copiedBottles = all.values.singleWhere(
|
||||
(t) => t.parentId == copiedSub.key,
|
||||
);
|
||||
expect(copiedBottles.title, 'Bottles');
|
||||
expect(copiedBottles.completed, isTrue, reason: 'copied as it was');
|
||||
});
|
||||
|
||||
test("deleting a project's or a section's tasks deletes only theirs, "
|
||||
'with their sub-tasks', () async {
|
||||
final phone = await device(alice);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue