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
4 changed files with 1050 additions and 125 deletions

View file

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

View file

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

View file

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

View file

@ -106,4 +106,14 @@ void main() {
],
);
});
test('a task was added when its UUID v7 says, its first 48 bits the '
'milliseconds since 1970; another ID says nothing', () {
expect(
addedAt('019a3e2f-8c00-7000-8000-000000000000'),
DateTime.fromMillisecondsSinceEpoch(0x019a3e2f8c00),
);
expect(addedAt('not-an-id'), isNull);
expect(addedAt('019a3e2f-8c00-4000-8000-000000000000'), isNull);
});
}