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
16 changed files with 1050 additions and 125 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue