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

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