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
2 changed files with 1050 additions and 125 deletions
|
|
@ -7,14 +7,16 @@ import 'package:halkyon_ui/src/layout/window_size.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows [builder]'s sheet, usually a [SheetScaffold], as a bottom sheet in
|
||||
/// windows below expanded and as a dialog from there on; completes with the
|
||||
/// sheet's result either way.
|
||||
/// Shows [builder]'s sheet, usually a [SheetScaffold], as a bottom sheet on
|
||||
/// phones and tablets in windows below expanded, and as a dialog from there
|
||||
/// on and on the desktop (Linux, macOS, Windows) at every width; completes
|
||||
/// with the sheet's result either way.
|
||||
///
|
||||
/// Expanded rather than Material's medium, because a bottom sheet is a touch
|
||||
/// surface: medium windows are mostly tablets, expanded ones mostly
|
||||
/// desktops, where a sheet rising from the bottom of a monitor reads as a
|
||||
/// phone screen moved over.
|
||||
/// phone screen moved over. That holds for a narrow window on the desktop
|
||||
/// too, so there it is always a dialog (the developer's, 1 Nov 2026).
|
||||
///
|
||||
/// A sheet that asks before discarding changes (with a `PopScope`) passes
|
||||
/// [swipeable] false: a bottom sheet swiped away closes without asking its
|
||||
|
|
@ -26,7 +28,7 @@ Future<T?> showAdaptiveSheet<T>(
|
|||
required WidgetBuilder builder,
|
||||
bool swipeable = true,
|
||||
}) {
|
||||
if (!context.windowSize.isAtLeastExpanded) {
|
||||
if (!context.windowSize.isAtLeastExpanded && !_onDesktop) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
|
|
@ -46,8 +48,11 @@ Future<T?> showAdaptiveSheet<T>(
|
|||
return showDialog<T>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
// More room than an alert's default: sheets hold forms and lists.
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
|
||||
// More room than an alert's default: sheets hold forms and lists; and
|
||||
// in a narrow desktop window less margin, so the form keeps its width.
|
||||
insetPadding: context.windowSize.isAtLeastMedium
|
||||
? const EdgeInsets.symmetric(horizontal: 40, vertical: 32)
|
||||
: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _Presentation(
|
||||
isDialog: true,
|
||||
|
|
@ -58,6 +63,17 @@ Future<T?> showAdaptiveSheet<T>(
|
|||
);
|
||||
}
|
||||
|
||||
/// Whether the app runs on the desktop, where sheets are dialogs at every
|
||||
/// width.
|
||||
bool get _onDesktop => switch (defaultTargetPlatform) {
|
||||
TargetPlatform.linux ||
|
||||
TargetPlatform.macOS ||
|
||||
TargetPlatform.windows => true,
|
||||
TargetPlatform.android ||
|
||||
TargetPlatform.iOS ||
|
||||
TargetPlatform.fuchsia => false,
|
||||
};
|
||||
|
||||
/// Tells a [SheetScaffold] how it shows: as a dialog or a bottom sheet, and
|
||||
/// whether a drag handle sits above it.
|
||||
class const _Presentation({
|
||||
|
|
@ -96,6 +112,15 @@ class const SheetScaffold({
|
|||
/// The sheet's title.
|
||||
required final String title,
|
||||
|
||||
/// Shown in the title's place where set, such as a path to what the sheet
|
||||
/// holds; [title] still names the sheet.
|
||||
final Widget? heading,
|
||||
|
||||
/// Whether a close button ends the header. A sheet whose [action] is a
|
||||
/// menu holding Close leaves it out; it still closes by back, a swipe, a
|
||||
/// tap beside it or Escape.
|
||||
final bool closeButton = true,
|
||||
|
||||
/// The content; a scroll view holds it, so it is a plain column rather
|
||||
/// than a list.
|
||||
required final Widget body,
|
||||
|
|
@ -163,7 +188,9 @@ class const SheetScaffold({
|
|||
inset(
|
||||
_Header(
|
||||
title: title,
|
||||
heading: heading,
|
||||
action: action,
|
||||
closeButton: closeButton,
|
||||
onClose: busy
|
||||
? null
|
||||
: onClose ?? () => Navigator.of(context).maybePop(),
|
||||
|
|
@ -261,26 +288,31 @@ class const SheetScaffold({
|
|||
/// swiped at all.
|
||||
class const _Header({
|
||||
required final String title,
|
||||
required final Widget? heading,
|
||||
required final Widget? action,
|
||||
required final bool closeButton,
|
||||
required final VoidCallback? onClose,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child:
|
||||
heading ??
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?action,
|
||||
IconButton(
|
||||
tooltip: HalkyonUiLocalizations.of(context).close,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: onClose,
|
||||
),
|
||||
if (closeButton)
|
||||
IconButton(
|
||||
tooltip: HalkyonUiLocalizations.of(context).close,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: onClose,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('is a bottom sheet below expanded windows', (tester) async {
|
||||
testWidgets('is a bottom sheet below expanded windows on a phone or a '
|
||||
'tablet', (tester) async {
|
||||
await open(tester, width: 839);
|
||||
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
|
|
@ -62,6 +63,23 @@ void main() {
|
|||
expect(find.byType(BottomSheet), findsNothing);
|
||||
});
|
||||
|
||||
for (final platform in [
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.macOS,
|
||||
TargetPlatform.windows,
|
||||
]) {
|
||||
testWidgets('is a dialog on the desktop however narrow the window '
|
||||
'(${platform.name})', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
await open(tester, width: 400);
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
|
||||
for (final width in [400.0, 1200.0]) {
|
||||
testWidgets('keeps its close button and footer in view above a long body '
|
||||
'(${width.round()} wide)', (tester) async {
|
||||
|
|
@ -184,6 +202,43 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
testWidgets("a heading of its own takes the title's place, and the close "
|
||||
'button may give way to its action', (tester) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
|
||||
supportedLocales: HalkyonUiLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => const SheetScaffold(
|
||||
title: 'Task',
|
||||
heading: Text('Inbox › Shopping'),
|
||||
closeButton: false,
|
||||
action: Text('More'),
|
||||
body: Text('Body'),
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Inbox › Shopping'), findsOneWidget);
|
||||
expect(find.text('Task'), findsNothing);
|
||||
expect(find.byTooltip('Close'), findsNothing);
|
||||
expect(find.text('More'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a body that fits shows no lines', (tester) async {
|
||||
await open(tester, width: 400);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue