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
12 changed files with 342 additions and 159 deletions
|
|
@ -266,6 +266,7 @@
|
|||
"planTitle": "Plan",
|
||||
"planShow": "Plan einblenden",
|
||||
"planHide": "Plan ausblenden",
|
||||
"planNoDate": "Ohne Datum",
|
||||
"planToPlan": "Zu planen",
|
||||
"planTimeBlocked": "Eingeplant",
|
||||
"planEmpty": "Nichts zu planen",
|
||||
|
|
|
|||
|
|
@ -861,6 +861,7 @@
|
|||
"@planHide": {
|
||||
"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": {
|
||||
"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'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1047,6 +1047,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get planHide => 'Plan ausblenden';
|
||||
|
||||
@override
|
||||
String get planNoDate => 'Ohne Datum';
|
||||
|
||||
@override
|
||||
String get planToPlan => 'Zu planen';
|
||||
|
||||
|
|
|
|||
|
|
@ -1047,6 +1047,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get planHide => 'Hide the Plan';
|
||||
|
||||
@override
|
||||
String get planNoDate => 'No date';
|
||||
|
||||
@override
|
||||
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`
|
||||
/// 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 = ({
|
||||
List<OpenTask> overdue,
|
||||
List<OpenTask> toPlan,
|
||||
List<OpenTask> timed,
|
||||
List<OpenTask> noDate,
|
||||
});
|
||||
|
||||
/// [TodayPlan] on [today]: the overdue tasks oldest first, today's by
|
||||
|
|
@ -113,12 +115,24 @@ TodayPlan planOf(
|
|||
for (final task in due)
|
||||
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 {
|
||||
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,
|
||||
|
|
|
|||
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,
|
||||
toPlan,
|
||||
timed,
|
||||
noDate,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// tasks ([planOf]) in three groups, each folding under its heading with its
|
||||
/// count: *Overdue* with Reschedule, *To plan* (today's without a time) and
|
||||
/// *Time-blocked* (today's with one), narrowed to the projects [projectIds]
|
||||
/// tasks ([planOf]) in groups, each folding under its heading with its
|
||||
/// count: *Overdue* with Reschedule, *To plan* (today's without a time),
|
||||
/// *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
|
||||
/// 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
|
||||
/// 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.
|
||||
|
|
@ -85,6 +86,7 @@ class _PlanPanelState() extends State<PlanPanel> {
|
|||
(_PlanGroup.overdue, l10n.overdue, plan.overdue),
|
||||
(_PlanGroup.toPlan, l10n.planToPlan, plan.toPlan),
|
||||
(_PlanGroup.timed, l10n.planTimeBlocked, plan.timed),
|
||||
(_PlanGroup.noDate, l10n.planNoDate, plan.noDate),
|
||||
];
|
||||
return DragTarget<TaskDrag>(
|
||||
key: PlanPanel.dropKey,
|
||||
|
|
@ -107,7 +109,7 @@ class _PlanPanelState() extends State<PlanPanel> {
|
|||
onTap: widget.onHeaderTap,
|
||||
),
|
||||
Expanded(
|
||||
child: plan.count == 0
|
||||
child: plan.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
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_tile.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_panel.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/plan_host.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.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
|
||||
/// 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
|
||||
/// ([PlanPanel]) its app bar's Plan button shows or hides: beside the day
|
||||
/// 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
|
||||
/// ([PlanHost]). A
|
||||
/// button adds a task for today. A reset of the person's keys waits above
|
||||
/// it (`KeysResetBanner`).
|
||||
class const TodayTab({super.key}) extends StatefulWidget {
|
||||
|
|
@ -46,39 +42,15 @@ class const TodayTab({super.key}) extends StatefulWidget {
|
|||
State<TodayTab> createState() => _TodayTabState();
|
||||
}
|
||||
|
||||
class _TodayTabState() extends State<TodayTab> {
|
||||
class _TodayTabState() extends State<TodayTab> with PlanHost<TodayTab> {
|
||||
/// Whether what is overdue is folded away.
|
||||
bool _overdueFolded = false;
|
||||
|
||||
/// The projects the Plan shows, for as long as the app runs (none: all).
|
||||
Set<String> _projects = const {};
|
||||
@override
|
||||
Key get planButtonKey => TodayTab.planKey;
|
||||
|
||||
/// Keeps the day where it was scrolled as the Plan comes and goes.
|
||||
final _grid = GlobalKey();
|
||||
|
||||
/// 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
|
||||
Key get planSheetKey => TodayTab.planSheetKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -91,30 +63,15 @@ class _TodayTabState() extends State<TodayTab> {
|
|||
final count = overdue.length + due.length;
|
||||
final view = state.settings.today;
|
||||
final calendar = view.layout == TodayLayout.calendar;
|
||||
final planned = planOf(state.tasks, today, projectIds: _projects).count;
|
||||
final planShown = _wide ? view.plan : _sheet;
|
||||
return TabScreen(
|
||||
title: l10n.shellTabToday,
|
||||
subtitle: state.loaded && count > 0 ? l10n.todayCount(count) : null,
|
||||
actions: [
|
||||
if (calendar)
|
||||
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),
|
||||
),
|
||||
),
|
||||
if (calendar) planButton(context, state, today),
|
||||
DisplayButton(target: todayDisplay),
|
||||
const SelectTasksMenu(),
|
||||
],
|
||||
floatingActionButton: calendar && !_wide && _sheet
|
||||
floatingActionButton: calendar && planSheetOpen
|
||||
? null
|
||||
: AddTaskButton(onPressed: () => showQuickAdd(context, day: today)),
|
||||
body: Column(
|
||||
|
|
@ -146,91 +103,18 @@ class _TodayTabState() extends State<TodayTab> {
|
|||
],
|
||||
busy: const {},
|
||||
);
|
||||
// The day's last hours can be scrolled above a phone's Plan.
|
||||
Widget grid({double foot = 88}) => HourGrid(
|
||||
key: _grid,
|
||||
days: 1,
|
||||
foot: foot,
|
||||
coloring: TaskColoring.priority,
|
||||
// The hour before now at the top, as the day's rest is to plan.
|
||||
firstHour: clock.now().hour - 1,
|
||||
calendarFor: calendarFor,
|
||||
);
|
||||
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 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
return plannedCalendar(
|
||||
context,
|
||||
shown: view.plan,
|
||||
grid: (key, foot) => HourGrid(
|
||||
key: key,
|
||||
days: 1,
|
||||
foot: foot,
|
||||
coloring: TaskColoring.priority,
|
||||
// The hour before now at the top, as the day's rest is to plan.
|
||||
firstHour: clock.now().hour - 1,
|
||||
calendarFor: calendarFor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/tabs/day_agenda.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/week_strip.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
|
||||
/// 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
|
||||
/// 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 {
|
||||
/// 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
|
||||
State<UpcomingTab> createState() => _UpcomingTabState();
|
||||
}
|
||||
|
||||
class _UpcomingTabState() extends State<UpcomingTab> {
|
||||
class _UpcomingTabState()
|
||||
extends State<UpcomingTab>
|
||||
with PlanHost<UpcomingTab> {
|
||||
Day? _selected;
|
||||
|
||||
@override
|
||||
Key get planButtonKey => UpcomingTab.planKey;
|
||||
|
||||
@override
|
||||
Key get planSheetKey => UpcomingTab.planSheetKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -58,18 +74,22 @@ class _UpcomingTabState() extends State<UpcomingTab> {
|
|||
return TabScreen(
|
||||
title: l10n.shellTabUpcoming,
|
||||
actions: [
|
||||
if (upcoming.layout == UpcomingLayout.threeDay)
|
||||
planButton(context, state, today),
|
||||
DisplayButton(target: upcomingDisplay),
|
||||
const SelectTasksMenu(),
|
||||
],
|
||||
floatingActionButton: AddTaskButton(
|
||||
onPressed: () => showQuickAdd(
|
||||
context,
|
||||
day: switch (_selected) {
|
||||
final day? when !(day < Day.of(clock.now())) => day,
|
||||
_ => Day.of(clock.now()),
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButton: planSheetOpen
|
||||
? null
|
||||
: AddTaskButton(
|
||||
onPressed: () => showQuickAdd(
|
||||
context,
|
||||
day: switch (_selected) {
|
||||
final day? when !(day < Day.of(clock.now())) => day,
|
||||
_ => Day.of(clock.now()),
|
||||
},
|
||||
),
|
||||
),
|
||||
body: !state.loaded
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: switch (upcoming.layout) {
|
||||
|
|
@ -80,10 +100,16 @@ class _UpcomingTabState() extends State<UpcomingTab> {
|
|||
calendarFor: calendarFor,
|
||||
),
|
||||
),
|
||||
UpcomingLayout.threeDay => HourGrid(
|
||||
coloring: upcoming.coloring,
|
||||
onStart: select,
|
||||
calendarFor: calendarFor,
|
||||
UpcomingLayout.threeDay => plannedCalendar(
|
||||
context,
|
||||
shown: state.settings.today.plan,
|
||||
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/today_icon.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_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
|
@ -1133,6 +1134,44 @@ void main() {
|
|||
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(
|
||||
'a click adds a task at that quarter hour, for half an hour',
|
||||
(tester) async {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ void main() {
|
|||
expect(plan.toPlan.map((t) => t.id), ['open']);
|
||||
expect(plan.timed.map((t) => t.id), ['eight', 'nine']);
|
||||
expect(plan.count, 4);
|
||||
expect(plan.noDate.map((t) => t.id), ['undated']);
|
||||
|
||||
final home = planOf(tasks, today, projectIds: {'home'});
|
||||
expect(home.overdue, isEmpty);
|
||||
|
|
@ -142,4 +143,35 @@ void main() {
|
|||
expect(home.timed.map((t) => t.id), ['eight']);
|
||||
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'],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue