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:
parent
4aec923476
commit
31ff8d7e91
13 changed files with 342 additions and 159 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
161
apps/todo/halkyon_todo_flutter/lib/src/tabs/plan_host.dart
Normal file
161
apps/todo/halkyon_todo_flutter/lib/src/tabs/plan_host.dart
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue