feat(todo): the Plan beside Upcoming, with what has no date (ADR 0033, D34)

Upcoming's 3-day grid gets the Plan Today's calendar has, through what
the two tabs now share (PlanHost). Its new last group holds the open
tasks of no day, so a week can be planned from the Inbox by dropping them
on an hour; they aren't counted on the button, which says what is due.

Whats-New: Upcoming's 3-day view has the Plan too, and tasks without a date can be dragged from it onto any day.
Whats-New-De: Auch die 3-Tage-Ansicht unter Demnächst hat den Plan, und Aufgaben ohne Datum lassen sich von dort auf jeden Tag ziehen.

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-05 19:37:55 +01:00
commit 31ff8d7e91
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 342 additions and 159 deletions

View file

@ -266,6 +266,7 @@
"planTitle": "Plan", "planTitle": "Plan",
"planShow": "Plan einblenden", "planShow": "Plan einblenden",
"planHide": "Plan ausblenden", "planHide": "Plan ausblenden",
"planNoDate": "Ohne Datum",
"planToPlan": "Zu planen", "planToPlan": "Zu planen",
"planTimeBlocked": "Eingeplant", "planTimeBlocked": "Eingeplant",
"planEmpty": "Nichts zu planen", "planEmpty": "Nichts zu planen",

View file

@ -861,6 +861,7 @@
"@planHide": { "@planHide": {
"description": "Tooltip of Today's Plan button and the Plan's close button while it shows (ADR 0033, D30)." "description": "Tooltip of Today's Plan button and the Plan's close button while it shows (ADR 0033, D30)."
}, },
"planNoDate": "No date",
"planToPlan": "To plan", "planToPlan": "To plan",
"@planToPlan": { "@planToPlan": {
"description": "Today's Plan (ADR 0033, D30): the group of today's tasks without a time, to be given one." "description": "Today's Plan (ADR 0033, D30): the group of today's tasks without a time, to be given one."

View file

@ -1696,6 +1696,12 @@ abstract class AppLocalizations {
/// **'Hide the Plan'** /// **'Hide the Plan'**
String get planHide; String get planHide;
/// No description provided for @planNoDate.
///
/// In en, this message translates to:
/// **'No date'**
String get planNoDate;
/// Today's Plan (ADR 0033, D30): the group of today's tasks without a time, to be given one. /// Today's Plan (ADR 0033, D30): the group of today's tasks without a time, to be given one.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1047,6 +1047,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get planHide => 'Plan ausblenden'; String get planHide => 'Plan ausblenden';
@override
String get planNoDate => 'Ohne Datum';
@override @override
String get planToPlan => 'Zu planen'; String get planToPlan => 'Zu planen';

View file

@ -1047,6 +1047,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get planHide => 'Hide the Plan'; String get planHide => 'Hide the Plan';
@override
String get planNoDate => 'No date';
@override @override
String get planToPlan => 'To plan'; String get planToPlan => 'To plan';

View file

@ -85,11 +85,13 @@ List<OpenTask> subTasksOf(List<OpenTask> tasks, String parentId) => [
} }
/// What Today's Plan holds (ADR 0033, D30, as Todoist's): the `overdue` /// What Today's Plan holds (ADR 0033, D30, as Todoist's): the `overdue`
/// tasks, today's without a time `toPlan`, and today's with one `timed`. /// tasks, today's without a time `toPlan`, today's with one `timed`, and
/// those of no day at all, `noDate` (D34), its sub-tasks staying with theirs.
typedef TodayPlan = ({ typedef TodayPlan = ({
List<OpenTask> overdue, List<OpenTask> overdue,
List<OpenTask> toPlan, List<OpenTask> toPlan,
List<OpenTask> timed, List<OpenTask> timed,
List<OpenTask> noDate,
}); });
/// [TodayPlan] on [today]: the overdue tasks oldest first, today's by /// [TodayPlan] on [today]: the overdue tasks oldest first, today's by
@ -113,12 +115,24 @@ TodayPlan planOf(
for (final task in due) for (final task in due)
if (kept(task) && task.content.date?.time != null) task, if (kept(task) && task.content.date?.time != null) task,
], ],
noDate: [
for (final task in tasks)
if (kept(task) &&
task.content.date == null &&
task.content.parentId == null &&
!task.content.completed)
task,
],
); );
} }
/// How many tasks the plan holds, as Today's Plan button counts them. /// How many tasks the plan holds for today, as the Plan button counts them;
/// those of no day wait with no such hurry, and count under their heading.
extension PlanCount on TodayPlan { extension PlanCount on TodayPlan {
int get count => overdue.length + toPlan.length + timed.length; int get count => overdue.length + toPlan.length + timed.length;
/// Whether there is nothing in it at all.
bool get isEmpty => count == 0 && noDate.isEmpty;
} }
/// What Upcoming shows from [today] on (ADR 0033, D8): the overdue tasks, /// What Upcoming shows from [today] on (ADR 0033, D8): the overdue tasks,

View file

@ -0,0 +1,161 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
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/pane_widths.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A tab whose hours have the Plan ([PlanPanel]) (ADR 0033, D30, D34): its
/// 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.
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 {};
/// Keeps the hours where they were scrolled as the Plan comes and goes.
final _grid = GlobalKey();
/// Whether the Plan stands beside the hours, as the body's width allows,
/// rather than over their foot (as last laid out).
bool _wide = false;
/// Whether the phone's Plan is open over the hours' foot.
bool _sheet = false;
/// Whether the phone's Plan has stepped down, one of its rows dragged.
bool _lowered = false;
/// The key of the app bar's Plan button.
Key get planButtonKey;
/// The key of the phone's Plan over the hours' foot.
Key get planSheetKey;
/// Whether the phone's Plan is open, where the add button makes way.
bool get planSheetOpen => !_wide && _sheet;
/// 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);
return;
}
final todo = PersonScope.of<TodoPerson>(context);
final view = state.settings.today;
await todo.settings.write(
state.settings.copyWith(today: view.copyWith(plan: !view.plan)),
);
}
/// The app bar's Plan button, counting what the Plan holds of [today].
Widget planButton(BuildContext context, TodoState state, Day today) {
final l10n = AppLocalizations.of(context);
final shown = _wide ? state.settings.today.plan : _sheet;
final planned = planOf(state.tasks, today, projectIds: _projects).count;
return Tooltip(
message: shown ? l10n.planHide : l10n.planShow,
child: TextButton.icon(
key: planButtonKey,
icon: Icon(
shown ? LucideIcons.panelRightClose : LucideIcons.panelRight,
),
label: Text(l10n.planButton(planned)),
onPressed: () => _togglePlan(state),
),
);
}
/// The hours [grid] makes (with its key and how much room to leave at its
/// foot) beside or under the Plan, beside them where [shown].
Widget plannedCalendar(
BuildContext context, {
required bool shown,
required Widget Function(Key key, double foot) grid,
}) {
void projects(Set<String> ids) => setState(() => _projects = ids);
return LayoutBuilder(
builder: (context, constraints) {
final wide = WindowSize.fromWidth(constraints.maxWidth).isAtLeastMedium;
if (wide != _wide) {
_wide = wide;
// The app bar's Plan button, built before, says what it does now.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {});
}
});
}
if (_wide) {
return shown
? SidePane(
kind: SidePaneKind.plan,
widths: PersonScope.of<TodoPerson>(context).paneWidths,
fallback: 340,
main: grid(_grid, 88),
pane: PlanPanel(
projectIds: _projects,
onProjects: projects,
),
)
: grid(_grid, 88);
}
// A phone's Plan over the hours' foot, above the floating bar; held
// rows lower it to its heading, the hours free behind it.
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 : 88)),
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),
),
),
child: PlanPanel(
projectIds: _projects,
onProjects: projects,
onClose: () => setState(() => _sheet = false),
onHeaderTap: () => setState(() => _lowered = false),
onDragging: (dragging) {
if (dragging != _lowered) {
setState(() => _lowered = dragging);
}
},
),
),
),
),
],
);
},
);
}
}

View file

@ -22,15 +22,16 @@ enum _PlanGroup() {
overdue, overdue,
toPlan, toPlan,
timed, timed,
noDate,
} }
/// Today's Plan (ADR 0033, D30, as Todoist's desktop Today): beside the day /// Today's Plan (ADR 0033, D30, as Todoist's desktop Today): beside the day
/// of hours from medium widths on, a sheet over its foot on a phone. Its /// of hours from medium widths on, a sheet over its foot on a phone. Its
/// tasks ([planOf]) in three groups, each folding under its heading with its /// tasks ([planOf]) in groups, each folding under its heading with its
/// count: *Overdue* with Reschedule, *To plan* (today's without a time) and /// count: *Overdue* with Reschedule, *To plan* (today's without a time),
/// *Time-blocked* (today's with one), narrowed to the projects [projectIds] /// *Time-blocked* (today's with one) and *No date* (D34), narrowed to the projects [projectIds]
/// names (none: all), picked from *All projects* ([onProjects]). A row held /// names (none: all), picked from *All projects* ([onProjects]). A row held
/// and dropped on an hour of the day goes there, today; a block of the day /// and dropped on an hour goes there, on that hour's day; a block of today
/// dropped on the Plan loses its time and stays today ([moveTask], which can /// dropped on the Plan loses its time and stays today ([moveTask], which can
/// be undone). [onDragging] hears when one of its rows is being dragged, as /// be undone). [onDragging] hears when one of its rows is being dragged, as
/// the phone's sheet then steps aside; [onClose] closes it where given. /// the phone's sheet then steps aside; [onClose] closes it where given.
@ -85,6 +86,7 @@ class _PlanPanelState() extends State<PlanPanel> {
(_PlanGroup.overdue, l10n.overdue, plan.overdue), (_PlanGroup.overdue, l10n.overdue, plan.overdue),
(_PlanGroup.toPlan, l10n.planToPlan, plan.toPlan), (_PlanGroup.toPlan, l10n.planToPlan, plan.toPlan),
(_PlanGroup.timed, l10n.planTimeBlocked, plan.timed), (_PlanGroup.timed, l10n.planTimeBlocked, plan.timed),
(_PlanGroup.noDate, l10n.planNoDate, plan.noDate),
]; ];
return DragTarget<TaskDrag>( return DragTarget<TaskDrag>(
key: PlanPanel.dropKey, key: PlanPanel.dropKey,
@ -107,7 +109,7 @@ class _PlanPanelState() extends State<PlanPanel> {
onTap: widget.onHeaderTap, onTap: widget.onHeaderTap,
), ),
Expanded( Expanded(
child: plan.count == 0 child: plan.isEmpty
? Center( ? Center(
child: Text( child: Text(
l10n.planEmpty, l10n.planEmpty,

View file

@ -15,9 +15,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart'; import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart'; import 'package:halkyon_todo_flutter/src/tabs/plan_host.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart'; import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -30,9 +28,7 @@ import 'package:material_ui/material_ui.dart';
/// overdue, folding under its heading, with Reschedule moving all of it to /// overdue, folding under its heading, with Reschedule moving all of it to
/// a day at once, then today's, headed by the day alone; each with its /// a day at once, then today's, headed by the day alone; each with its
/// project. As a calendar, today's hours ([HourGrid], one day) and the Plan /// project. As a calendar, today's hours ([HourGrid], one day) and the Plan
/// ([PlanPanel]) its app bar's Plan button shows or hides: beside the day /// ([PlanHost]). A
/// from a medium width on (remembered), over its foot on a phone, stepping
/// down out of the way while one of its rows is dragged onto an hour. A
/// button adds a task for today. A reset of the person's keys waits above /// button adds a task for today. A reset of the person's keys waits above
/// it (`KeysResetBanner`). /// it (`KeysResetBanner`).
class const TodayTab({super.key}) extends StatefulWidget { class const TodayTab({super.key}) extends StatefulWidget {
@ -46,39 +42,15 @@ class const TodayTab({super.key}) extends StatefulWidget {
State<TodayTab> createState() => _TodayTabState(); State<TodayTab> createState() => _TodayTabState();
} }
class _TodayTabState() extends State<TodayTab> { class _TodayTabState() extends State<TodayTab> with PlanHost<TodayTab> {
/// Whether what is overdue is folded away. /// Whether what is overdue is folded away.
bool _overdueFolded = false; bool _overdueFolded = false;
/// The projects the Plan shows, for as long as the app runs (none: all). @override
Set<String> _projects = const {}; Key get planButtonKey => TodayTab.planKey;
/// Keeps the day where it was scrolled as the Plan comes and goes. @override
final _grid = GlobalKey(); Key get planSheetKey => TodayTab.planSheetKey;
/// Whether the Plan stands beside the day, as the body's width allows,
/// rather than over its foot (as last laid out).
bool _wide = false;
/// Whether the phone's Plan is open over the day's foot.
bool _sheet = false;
/// Whether the phone's Plan has stepped down, one of its rows dragged.
bool _lowered = false;
/// The Plan shown or hidden: beside the day, remembered with the
/// person's settings; on a phone, its sheet.
Future<void> _togglePlan(TodoState state) async {
if (!_wide) {
setState(() => _sheet = !_sheet);
return;
}
final todo = PersonScope.of<TodoPerson>(context);
final view = state.settings.today;
await todo.settings.write(
state.settings.copyWith(today: view.copyWith(plan: !view.plan)),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -91,30 +63,15 @@ class _TodayTabState() extends State<TodayTab> {
final count = overdue.length + due.length; final count = overdue.length + due.length;
final view = state.settings.today; final view = state.settings.today;
final calendar = view.layout == TodayLayout.calendar; final calendar = view.layout == TodayLayout.calendar;
final planned = planOf(state.tasks, today, projectIds: _projects).count;
final planShown = _wide ? view.plan : _sheet;
return TabScreen( return TabScreen(
title: l10n.shellTabToday, title: l10n.shellTabToday,
subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null, subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null,
actions: [ actions: [
if (calendar) if (calendar) planButton(context, state, today),
Tooltip(
message: planShown ? l10n.planHide : l10n.planShow,
child: TextButton.icon(
key: TodayTab.planKey,
icon: Icon(
planShown
? LucideIcons.panelRightClose
: LucideIcons.panelRight,
),
label: Text(l10n.planButton(planned)),
onPressed: () => _togglePlan(state),
),
),
DisplayButton(target: todayDisplay), DisplayButton(target: todayDisplay),
const SelectTasksMenu(), const SelectTasksMenu(),
], ],
floatingActionButton: calendar && !_wide && _sheet floatingActionButton: calendar && planSheetOpen
? null ? null
: AddTaskButton(onPressed: () => showQuickAdd(context, day: today)), : AddTaskButton(onPressed: () => showQuickAdd(context, day: today)),
body: Column( body: Column(
@ -146,91 +103,18 @@ class _TodayTabState() extends State<TodayTab> {
], ],
busy: const {}, busy: const {},
); );
// The day's last hours can be scrolled above a phone's Plan. return plannedCalendar(
Widget grid({double foot = 88}) => HourGrid( context,
key: _grid, shown: view.plan,
days: 1, grid: (key, foot) => HourGrid(
foot: foot, key: key,
coloring: TaskColoring.priority, days: 1,
// The hour before now at the top, as the day's rest is to plan. foot: foot,
firstHour: clock.now().hour - 1, coloring: TaskColoring.priority,
calendarFor: calendarFor, // The hour before now at the top, as the day's rest is to plan.
); firstHour: clock.now().hour - 1,
void projects(Set<String> ids) => setState(() => _projects = ids); calendarFor: calendarFor,
return LayoutBuilder( ),
builder: (context, constraints) {
final wide = WindowSize.fromWidth(constraints.maxWidth).isAtLeastMedium;
if (wide != _wide) {
_wide = wide;
// The app bar's Plan button, built before, says what it does now.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {});
}
});
}
if (_wide) {
return view.plan
? SidePane(
kind: SidePaneKind.plan,
widths: PersonScope.of<TodoPerson>(context).paneWidths,
fallback: 340,
main: grid(),
pane: PlanPanel(
projectIds: _projects,
onProjects: projects,
),
)
: grid();
}
// A phone's Plan over the day's foot, above the floating bar; held
// rows lower it to its heading, the day free behind it.
final cover = FloatingNavigationBar.coverOf(context);
final height = constraints.maxHeight * 0.5;
const heading = 56.0;
return Stack(
children: [
Positioned.fill(child: grid(foot: _sheet ? height : 88)),
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: TodayTab.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: () => setState(() => _sheet = false),
onHeaderTap: () => setState(() => _lowered = false),
onDragging: (dragging) {
if (dragging != _lowered) {
setState(() => _lowered = dragging);
}
},
),
),
),
),
],
);
},
); );
} }

View file

@ -13,6 +13,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/tabs/day_agenda.dart'; import 'package:halkyon_todo_flutter/src/tabs/day_agenda.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart'; import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_host.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart'; import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart'; import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -21,15 +22,30 @@ import 'package:material_ui/material_ui.dart';
/// Upcoming (ADR 0033, D2, D8): every task of the person's from today on /// Upcoming (ADR 0033, D2, D8): every task of the person's from today on
/// that its Display keeps (D17, in their settings), as an agenda /// that its Display keeps (D17, in their settings), as an agenda
/// ([DayAgenda], its project at the end of each row) or three days side by /// ([DayAgenda], its project at the end of each row) or three days side by
/// side ([HourGrid]); the add button adds a task for the day picked. /// side ([HourGrid]), with the Plan ([PlanHost], D34); the add button adds a
/// task for the day picked.
class const UpcomingTab({super.key}) extends StatefulWidget { class const UpcomingTab({super.key}) extends StatefulWidget {
/// The key of the app bar's Plan button (D34).
static const planKey = ValueKey('upcoming-plan');
/// The key of the phone's Plan over the hours' foot (D34).
static const planSheetKey = ValueKey('upcoming-plan-sheet');
@override @override
State<UpcomingTab> createState() => _UpcomingTabState(); State<UpcomingTab> createState() => _UpcomingTabState();
} }
class _UpcomingTabState() extends State<UpcomingTab> { class _UpcomingTabState()
extends State<UpcomingTab>
with PlanHost<UpcomingTab> {
Day? _selected; Day? _selected;
@override
Key get planButtonKey => UpcomingTab.planKey;
@override
Key get planSheetKey => UpcomingTab.planSheetKey;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -58,18 +74,22 @@ class _UpcomingTabState() extends State<UpcomingTab> {
return TabScreen( return TabScreen(
title: l10n.shellTabUpcoming, title: l10n.shellTabUpcoming,
actions: [ actions: [
if (upcoming.layout == UpcomingLayout.threeDay)
planButton(context, state, today),
DisplayButton(target: upcomingDisplay), DisplayButton(target: upcomingDisplay),
const SelectTasksMenu(), const SelectTasksMenu(),
], ],
floatingActionButton: AddTaskButton( floatingActionButton: planSheetOpen
onPressed: () => showQuickAdd( ? null
context, : AddTaskButton(
day: switch (_selected) { onPressed: () => showQuickAdd(
final day? when !(day < Day.of(clock.now())) => day, context,
_ => Day.of(clock.now()), day: switch (_selected) {
}, final day? when !(day < Day.of(clock.now())) => day,
), _ => Day.of(clock.now()),
), },
),
),
body: !state.loaded body: !state.loaded
? const Center(child: AppLoadingIndicator()) ? const Center(child: AppLoadingIndicator())
: switch (upcoming.layout) { : switch (upcoming.layout) {
@ -80,10 +100,16 @@ class _UpcomingTabState() extends State<UpcomingTab> {
calendarFor: calendarFor, calendarFor: calendarFor,
), ),
), ),
UpcomingLayout.threeDay => HourGrid( UpcomingLayout.threeDay => plannedCalendar(
coloring: upcoming.coloring, context,
onStart: select, shown: state.settings.today.plan,
calendarFor: calendarFor, grid: (key, foot) => HourGrid(
key: key,
foot: foot,
coloring: upcoming.coloring,
onStart: select,
calendarFor: calendarFor,
),
), ),
}, },
); );

View file

@ -36,6 +36,7 @@ import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart'; import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart'; import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart'; import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/upcoming_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart'; import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -1133,6 +1134,44 @@ void main() {
expect(find.text('Far'), findsOneWidget); expect(find.text('Far'), findsOneWidget);
}); });
testWidgets('its Plan shows in 3-day alone; a task of no date dropped on '
"a later day's hour gets that day and time (D34)", (tester) async {
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(title: 'Someday', projectId: inbox()),
);
await goTab(tester, 'Upcoming');
expect(find.byKey(UpcomingTab.planKey), findsNothing);
await threeDay(tester);
expect(find.byKey(UpcomingTab.planKey), findsOneWidget);
tester.view.physicalSize = const Size(1400, 900);
await tester.pumpAndSettle();
expect(find.text('No date'), findsOneWidget);
final today = Day.of(DateTime.now());
final row = tester.getCenter(find.text('Someday'));
final column = tester.getTopLeft(
find.byKey(HourGrid.columnKey(today.plus(1))),
);
final gesture = await tester.startGesture(row);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 50));
final to = column + const Offset(30, 11 * 60 + 5);
for (var step = 1; step <= 10; step++) {
await gesture.moveTo(Offset.lerp(row, to, step / 10)!);
await tester.pump(const Duration(milliseconds: 16));
}
await gesture.up();
await tester.pumpAndSettle();
final date = (await tester.runAsync(
() => tasks().all(),
))!.single.content!.date!;
expect(date.day, today.plus(1));
expect(date.time, inInclusiveRange(10 * 60 + 30, 11 * 60 + 30));
expect(date.time! % 15, 0);
});
testWidgets( testWidgets(
'a click adds a task at that quarter hour, for half an hour', 'a click adds a task at that quarter hour, for half an hour',
(tester) async { (tester) async {

View file

@ -135,6 +135,7 @@ void main() {
expect(plan.toPlan.map((t) => t.id), ['open']); expect(plan.toPlan.map((t) => t.id), ['open']);
expect(plan.timed.map((t) => t.id), ['eight', 'nine']); expect(plan.timed.map((t) => t.id), ['eight', 'nine']);
expect(plan.count, 4); expect(plan.count, 4);
expect(plan.noDate.map((t) => t.id), ['undated']);
final home = planOf(tasks, today, projectIds: {'home'}); final home = planOf(tasks, today, projectIds: {'home'});
expect(home.overdue, isEmpty); expect(home.overdue, isEmpty);
@ -142,4 +143,35 @@ void main() {
expect(home.timed.map((t) => t.id), ['eight']); expect(home.timed.map((t) => t.id), ['eight']);
expect(home.count, 2); expect(home.count, 2);
}); });
test('the Plan keeps the tasks of no day apart, uncounted (D34)', () {
const today = Day(2026, 11, 5);
OpenTask task(String id, {String? parentId, bool completed = false}) => (
id: id,
content: TaskContent(
title: id,
projectId: id == 'other' ? 'home' : 'inbox',
parentId: parentId,
completedAt: completed ? DateTime(2026, 11, 4) : null,
),
);
final tasks = [
task('free'),
task('other'),
task('sub', parentId: 'free'),
task('done', completed: true),
];
final plan = planOf(tasks, today);
expect([for (final task in plan.noDate) task.id], ['free', 'other']);
expect(plan.count, 0);
expect(plan.isEmpty, isFalse);
expect(
[
for (final task in planOf(tasks, today, projectIds: {'home'}).noDate)
task.id,
],
['other'],
);
});
} }

View file

@ -730,6 +730,17 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
no more half hours. A tap that stops the day scrolling doesn't add, as a fling no more half hours. A tap that stops the day scrolling doesn't add, as a fling
keeps pointers from what it scrolls. keeps pointers from what it scrolls.
- **D34 The Plan beside Upcoming, with what has no date** (the developer's, 5 Nov 2026,
the agent's recommendation): the Plan (`PlanPanel`) stands beside Upcoming's 3-day
grid as beside Today's calendar, through what the two now share (`PlanHost`), a row
dropped on an hour or *All day* going to that day. It shows or hides for both calendars
(`TodayView.plan`, kept as it was: a second setting was the alternative, for a choice
people hardly make per tab); the projects picked are each tab's. It stays about today:
a block dropped on it loses its time only if it is today's. *No date*, its last
group, holds the open tasks of no day, sub-tasks staying with theirs, so that a week
is planned from the Inbox; it isn't in the button's count, which says what is due. A
routine's templates (the developer's idea) wait for what a routine is.
## Consequences ## Consequences
- No email reminder, morning overview or evening review: the server can't read what is - No email reminder, morning overview or evening review: the server can't read what is