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:
Tizian 「ティツィアーン」 2026-11-01 16:53:38 +01:00
commit b908019969
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 1050 additions and 125 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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

View file

@ -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';
}

View file

@ -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`).

View file

@ -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(

View file

@ -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),
),
),
],
);
}
}

View file

@ -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,
),
),
],
);

View file

@ -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,
),
),
],
),

View file

@ -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,
),
);

View file

@ -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();

View file

@ -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,
];
}

View file

@ -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,
};
}

View file

@ -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,
),
),
),
],
],
),
);
}
}

View file

@ -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),
);
}

View file

@ -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