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:
parent
90f9415e72
commit
ccdf22b936
8 changed files with 251 additions and 61 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue