fix(todo): the Plan's button before search, and its sheet without the navigation bar

Today's and Upcoming's Plan button stood between search and Display; it
stands before the three every tab has now (TabScreen's `leadingActions`).
On a phone, where the Plan opens over the hours' foot, its icon is
Lucide's panel-bottom-open (panel-bottom-close while open); beside the
hours on wider windows it keeps the panel at the right.

While a phone's Plan is open, the navigation bar steps aside, as it does
while tasks are being chosen: it floated over the Plan's tasks, and the
Plan can't be a modal sheet, its rows being dragged out onto the hours.
`NavigationBarHold`, kept by TodoShell, holds it away while anyone asks;
closing the Plan, or back, which closes it first, brings it back.

The Plan's heading put its projects and close button after a spacer of
one share and a flexible of four, leaving a fifth of the room after
them; they end at its edge now, as the rest of it.

Whats-New: The Plan's button stands before search, and while the Plan is open the navigation bar makes way for it.
Whats-New-De: Der Plan-Knopf steht vor der Suche, und solange der Plan offen ist, macht ihm die Navigationsleiste Platz.
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-07 23:15:17 +01:00
commit ccdf22b936
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 251 additions and 61 deletions

View file

@ -0,0 +1,47 @@
import 'package:material_ui/material_ui.dart';
/// Who holds the navigation bar away below medium windows (the developer's
/// review, 7 Nov 2026): a phone's Plan over the hours' foot, its rows
/// dragged onto them, so it can't be a modal sheet, yet the bar floated
/// over its tasks. Held by anyone, the bar steps aside; released by all,
/// it comes back. `TodoShell` keeps it, and [NavigationBarHoldScope] hands
/// it down.
class NavigationBarHold() extends ChangeNotifier {
final _holders = <Object>{};
bool _disposed = false;
/// Whether anyone holds the bar away.
bool get held => _holders.isNotEmpty;
/// [holder] holds the bar away, or no longer where [hold] is false.
void set(Object holder, {required bool hold}) {
if (_disposed) {
return;
}
final changed = hold ? _holders.add(holder) : _holders.remove(holder);
if (changed) {
notifyListeners();
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
/// Hands [notifier] down to the tabs.
class const NavigationBarHoldScope({
required final NavigationBarHold notifier,
required super.child,
super.key,
}) extends InheritedWidget {
/// The hold above [context], if any; nothing rebuilds as it changes.
static NavigationBarHold? maybeOf(BuildContext context) =>
context.getInheritedWidgetOfExactType<NavigationBarHoldScope>()?.notifier;
@override
bool updateShouldNotify(NavigationBarHoldScope oldWidget) =>
notifier != oldWidget.notifier;
}

View file

@ -4,6 +4,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/tabs/navigation_bar_hold.dart';
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
@ -15,7 +16,8 @@ import 'package:material_ui/material_ui.dart';
/// app bar's button shows or hides it, beside the hours from a medium width
/// on (remembered with the person's settings, for every calendar alike),
/// over their foot on a phone, stepping down out of the way while one of
/// its rows is dragged onto an hour.
/// its rows is dragged onto an hour, the navigation bar away while it is
/// open there.
mixin PlanHost<T extends StatefulWidget> on State<T> {
/// The projects the Plan shows, for as long as the app runs (none: all).
Set<String> _projects = const {};
@ -42,11 +44,41 @@ mixin PlanHost<T extends StatefulWidget> on State<T> {
/// Whether the phone's Plan is open, where the add button makes way.
bool get planSheetOpen => !_wide && _sheet;
/// The navigation bar held away while the phone's Plan is open, its
/// rows free of it (the developer's review, 7 Nov 2026).
NavigationBarHold? _hold;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_hold = NavigationBarHoldScope.maybeOf(context);
}
/// The phone's Plan opened or closed, the navigation bar going or
/// coming with it.
void _setSheet(bool open) {
setState(() => _sheet = open);
_syncBar();
}
void _syncBar() => _hold?.set(this, hold: planSheetOpen);
@override
void dispose() {
final hold = _hold;
// Not while the tree is being taken down, which the bar's frame is part
// of: just after.
WidgetsBinding.instance.addPostFrameCallback(
(_) => hold?.set(this, hold: false),
);
super.dispose();
}
/// The Plan shown or hidden: beside the hours, remembered with the
/// person's settings; on a phone, its sheet.
Future<void> _togglePlan(TodoState state) async {
if (!_wide) {
setState(() => _sheet = !_sheet);
_setSheet(!_sheet);
return;
}
final todo = PersonScope.of<TodoPerson>(context);
@ -65,9 +97,14 @@ mixin PlanHost<T extends StatefulWidget> on State<T> {
message: shown ? l10n.planHide : l10n.planShow,
child: TextButton.icon(
key: planButtonKey,
icon: Icon(
shown ? LucideIcons.panelRightClose : LucideIcons.panelRight,
),
// Where the Plan opens: beside the hours, or over their foot on a
// phone (the developer's review, 7 Nov 2026).
icon: Icon(switch ((_wide, shown)) {
(true, true) => LucideIcons.panelRightClose,
(true, false) => LucideIcons.panelRight,
(false, true) => LucideIcons.panelBottomClose,
(false, false) => LucideIcons.panelBottomOpen,
}),
label: Text(l10n.planButton(planned)),
onPressed: () => _togglePlan(state),
),
@ -91,6 +128,7 @@ mixin PlanHost<T extends StatefulWidget> on State<T> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {});
_syncBar();
}
});
}
@ -113,47 +151,56 @@ mixin PlanHost<T extends StatefulWidget> on State<T> {
final cover = FloatingNavigationBar.coverOf(context);
final height = constraints.maxHeight * 0.5;
const heading = 56.0;
return Stack(
children: [
Positioned.fill(child: grid(_grid, _sheet ? height : 0)),
if (_sheet)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: height + cover,
child: AnimatedSlide(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
offset: Offset(
0,
_lowered ? (height - heading) / (height + cover) : 0,
),
child: Material(
key: planSheetKey,
elevation: 8,
color: Theme.of(context).colorScheme.surfaceContainerLow,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
// Back closes the Plan first, the navigation bar away meanwhile.
return PopScope(
canPop: !_sheet,
onPopInvokedWithResult: (popped, _) {
if (!popped && _sheet) {
_setSheet(false);
}
},
child: Stack(
children: [
Positioned.fill(child: grid(_grid, _sheet ? height : 0)),
if (_sheet)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: height + cover,
child: AnimatedSlide(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
offset: Offset(
0,
_lowered ? (height - heading) / (height + cover) : 0,
),
child: PlanPanel(
projectIds: _projects,
onProjects: projects,
onClose: () => setState(() => _sheet = false),
onHeaderTap: () => setState(() => _lowered = false),
onDragging: (dragging) {
if (dragging != _lowered) {
setState(() => _lowered = dragging);
}
},
child: Material(
key: planSheetKey,
elevation: 8,
color: Theme.of(context).colorScheme.surfaceContainerLow,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: PlanPanel(
projectIds: _projects,
onProjects: projects,
onClose: () => _setSheet(false),
onHeaderTap: () => setState(() => _lowered = false),
onDragging: (dragging) {
if (dragging != _lowered) {
setState(() => _lowered = dragging);
}
},
),
),
),
),
),
],
],
),
);
},
);

View file

@ -231,20 +231,24 @@ class const _PlanHeading({
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Flexible(
flex: 4,
child: TextButton.icon(
key: PlanPanel.projectsKey,
iconAlignment: IconAlignment.end,
icon: const Icon(LucideIcons.chevronDown, size: 18),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// At the end, beside the close button (the developer's review,
// 7 Nov 2026: a spacer and a flexible of four shares left a
// fifth of the room after them), shortened where it is long.
Expanded(
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton.icon(
key: PlanPanel.projectsKey,
iconAlignment: IconAlignment.end,
icon: const Icon(LucideIcons.chevronDown, size: 18),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onPressed: () =>
_pickProjects(context, projectIds, onProjects),
),
onPressed: () =>
_pickProjects(context, projectIds, onProjects),
),
),
if (onClose case final close?)

View file

@ -22,9 +22,14 @@ class const TabScreen({
/// The tab's content.
required final Widget body,
/// The app bar's actions.
/// The app bar's actions, after search.
final List<Widget> actions = const [],
/// The app bar's actions before search, the tab's own beside the ones
/// every tab has (Today's and Upcoming's Plan, the developer's review, 7
/// Nov 2026).
final List<Widget> leadingActions = const [],
/// The screen's button, above the navigation bar.
final Widget? floatingActionButton,
@ -100,6 +105,7 @@ class const TabScreen({
actions: choosing
? const []
: [
...leadingActions,
if (search) const SearchButton(),
...actions,
const SizedBox(width: 4),

View file

@ -67,8 +67,8 @@ class _TodayTabState() extends State<TodayTab> with PlanHost<TodayTab> {
return TabScreen(
title: l10n.shellTabToday,
subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null,
leadingActions: [if (calendar) planButton(context, state, today)],
actions: [
if (calendar) planButton(context, state, today),
DisplayButton(target: todayDisplay),
const SelectTasksMenu(),
],

View file

@ -4,6 +4,7 @@ import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/tabs/navigation_bar_hold.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -29,9 +30,13 @@ class _TodoShellState() extends State<TodoShell> {
/// what to do with them takes the navigation bar's place.
final _selection = TaskSelection();
/// Whoever holds the navigation bar away (a phone's Plan).
final _hold = NavigationBarHold();
@override
void dispose() {
_selection.dispose();
_hold.dispose();
super.dispose();
}
@ -42,13 +47,13 @@ class _TodoShellState() extends State<TodoShell> {
return TaskSelectionScope(
notifier: _selection,
child: ListenableBuilder(
listenable: _selection,
listenable: Listenable.merge([_selection, _hold]),
builder: (context, child) => WhatsNewOnUpdate(
child: NavigationFrame(
appName: l10n.appTitle,
icon: todoIcon,
selectedIndex: shell.currentIndex,
hidesNavigationBar: _selection.active,
hidesNavigationBar: _selection.active || _hold.held,
// A tab chosen again goes back to its first screen; choosing tasks
// ends with the tab.
onSelected: (index) {
@ -88,7 +93,10 @@ class _TodoShellState() extends State<TodoShell> {
),
),
// From expanded windows on, a task opens beside the list (D2).
child: TaskPane(child: shell),
child: NavigationBarHoldScope(
notifier: _hold,
child: TaskPane(child: shell),
),
),
);
}

View file

@ -78,9 +78,11 @@ class _UpcomingTabState()
void select(Day day) => setState(() => _selected = day);
return TabScreen(
title: l10n.shellTabUpcoming,
actions: [
leadingActions: [
if (upcoming.layout == UpcomingLayout.threeDay)
planButton(context, state, today),
],
actions: [
DisplayButton(target: upcomingDisplay),
const SelectTasksMenu(),
],

View file

@ -1963,6 +1963,12 @@ void main() {
expect(find.byKey(UpcomingTab.planKey), findsNothing);
await threeDay(tester);
expect(find.byKey(UpcomingTab.planKey), findsOneWidget);
// Before search, Display and the menu every tab has (the
// developer's review, 7 Nov 2026).
expect(
tester.getTopRight(find.byKey(UpcomingTab.planKey)).dx,
lessThanOrEqualTo(tester.getTopLeft(find.byTooltip('Search')).dx),
);
tester.view.physicalSize = const Size(1400, 900);
await tester.pumpAndSettle();
expect(find.text('No date'), findsOneWidget);
@ -2690,6 +2696,76 @@ void main() {
});
});
testWidgets('on a phone, Plan stands before search, pointing down where '
'it opens; open, the navigation bar steps aside until it is '
'closed or back is pressed, its projects and close button at its '
"end (the developer's review, 7 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
// A wide phone, room to spare beside the heading's projects.
tester.view.physicalSize = const Size(560, 900);
await calendar(tester);
final plan = find.byKey(TodayTab.planKey);
final search = tester.getTopLeft(find.byTooltip('Search')).dx;
expect(tester.getTopRight(plan).dx, lessThanOrEqualTo(search));
expect(
search,
lessThan(tester.getTopLeft(find.byTooltip('Display')).dx),
);
expect(
find.descendant(
of: plan,
matching: find.byIcon(LucideIcons.panelBottomOpen),
),
findsOneWidget,
);
expect(find.byType(NavigationBar), findsOneWidget);
await tester.tap(plan);
await tester.pumpAndSettle();
final sheet = find.byKey(TodayTab.planSheetKey);
expect(sheet, findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(
find.descendant(
of: plan,
matching: find.byIcon(LucideIcons.panelBottomClose),
),
findsOneWidget,
);
expect(
tester.getBottomLeft(sheet).dy,
900,
reason: 'down to the foot, no bar below it',
);
final close = find.ancestor(
of: find.descendant(
of: sheet,
matching: find.byTooltip('Hide the Plan'),
),
matching: find.byType(IconButton),
);
expect(tester.getTopRight(close).dx, tester.getTopRight(sheet).dx - 4);
expect(
tester.getTopRight(find.byKey(PlanPanel.projectsKey)).dx,
moreOrLessEquals(tester.getTopLeft(close).dx, epsilon: 1),
);
await tester.tap(close);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(find.byType(NavigationBar), findsOneWidget);
await tester.tap(plan);
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsNothing);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byKey(TodayTab.planKey), findsOneWidget, reason: 'stays');
});
testWidgets('on a phone, Plan opens a sheet over the day; a row held '
'there lowers it out of the way and dropped on 14:00 is planned '
'then', (tester) async {