feat(todo): the Inbox's Calendar as an Agenda or 3-day

As Todoist's Display in the Inbox: once its layout is Calendar, a row
"Layout: Agenda" opens a small choice of Agenda or 3-day; 3-day shows the
Inbox's tasks in Upcoming's HourGrid, whatever the window's width, where a
tap on an hour adds to the Inbox. Kept as the project's other Display
choices, in its sealed view (`calendarLayout`, written only when it isn't
the Agenda; a value this version doesn't know reads as the Agenda).
Another project's Calendar stays the agenda (or the month), as Todoist
offers the choice in the Inbox alone.

Whats-New: The Inbox's Calendar can show three days of hours: Display, Layout, 3-day.
Whats-New-De: Der Kalender des Eingangs kann drei Tage mit ihren Stunden zeigen: Anzeige, Layout, 3 Tage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-07 22:59:46 +01:00
commit 71b5337d21
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 190 additions and 8 deletions

View file

@ -49,6 +49,8 @@ Map<String, Object?> encodeView(ProjectView view) => {
if (view.layout != ProjectLayout.list) 'layout': view.layout.name, if (view.layout != ProjectLayout.list) 'layout': view.layout.name,
if (view.completed) 'completed': true, if (view.completed) 'completed': true,
if (view.occurrences) 'occurrences': true, if (view.occurrences) 'occurrences': true,
if (view.calendarLayout != CalendarLayout.agenda)
'calendarLayout': view.calendarLayout.name,
if (view.grouping != TaskGrouping.none) 'grouping': view.grouping.name, if (view.grouping != TaskGrouping.none) 'grouping': view.grouping.name,
if (view.sorting != TaskSorting.manual) 'sorting': view.sorting.name, if (view.sorting != TaskSorting.manual) 'sorting': view.sorting.name,
if (view.date != DayRange.all) 'date': view.date.name, if (view.date != DayRange.all) 'date': view.date.name,
@ -78,6 +80,11 @@ ProjectView decodeView(Object? json) {
layout: named(ProjectLayout.values, 'layout', ProjectLayout.list), layout: named(ProjectLayout.values, 'layout', ProjectLayout.list),
completed: json['completed'] == true, completed: json['completed'] == true,
occurrences: json['occurrences'] == true, occurrences: json['occurrences'] == true,
calendarLayout: named(
CalendarLayout.values,
'calendarLayout',
CalendarLayout.agenda,
),
grouping: named(TaskGrouping.values, 'grouping', TaskGrouping.none), grouping: named(TaskGrouping.values, 'grouping', TaskGrouping.none),
sorting: named(TaskSorting.values, 'sorting', TaskSorting.manual), sorting: named(TaskSorting.values, 'sorting', TaskSorting.manual),
date: named(DayRange.values, 'date', DayRange.all), date: named(DayRange.values, 'date', DayRange.all),

View file

@ -12,6 +12,14 @@ enum ProjectLayout() {
table, table,
} }
/// How the Inbox lays out its Calendar (the developer's review, 7 Nov 2026,
/// as Todoist's Display offers it in the Inbox alone): day under day, or
/// three days of hours side by side, as Upcoming's.
enum CalendarLayout() {
agenda,
threeDay,
}
/// A column of a project's table (D36); the task's stands among them and /// A column of a project's table (D36); the task's stands among them and
/// moves as they do, but can't be hidden. /// moves as they do, but can't be hidden.
enum TaskColumn() { enum TaskColumn() {
@ -111,6 +119,9 @@ final class const ProjectView({
/// Whether a Calendar shows repeating tasks on their later days too. /// Whether a Calendar shows repeating tasks on their later days too.
final bool occurrences = false, final bool occurrences = false,
/// How a Calendar lays out its days, the Inbox's alone choosing.
final CalendarLayout calendarLayout = CalendarLayout.agenda,
final TaskGrouping grouping = TaskGrouping.none, final TaskGrouping grouping = TaskGrouping.none,
final TaskSorting sorting = TaskSorting.manual, final TaskSorting sorting = TaskSorting.manual,
final DayRange date = DayRange.all, final DayRange date = DayRange.all,
@ -230,6 +241,7 @@ final class const ProjectView({
ProjectLayout? layout, ProjectLayout? layout,
bool? completed, bool? completed,
bool? occurrences, bool? occurrences,
CalendarLayout? calendarLayout,
TaskGrouping? grouping, TaskGrouping? grouping,
TaskSorting? sorting, TaskSorting? sorting,
DayRange? date, DayRange? date,
@ -244,6 +256,7 @@ final class const ProjectView({
layout: layout ?? this.layout, layout: layout ?? this.layout,
completed: completed ?? this.completed, completed: completed ?? this.completed,
occurrences: occurrences ?? this.occurrences, occurrences: occurrences ?? this.occurrences,
calendarLayout: calendarLayout ?? this.calendarLayout,
grouping: grouping ?? this.grouping, grouping: grouping ?? this.grouping,
sorting: sorting ?? this.sorting, sorting: sorting ?? this.sorting,
date: date ?? this.date, date: date ?? this.date,
@ -261,6 +274,7 @@ final class const ProjectView({
layout, layout,
completed, completed,
occurrences, occurrences,
calendarLayout,
grouping, grouping,
sorting, sorting,
date, date,

View file

@ -100,6 +100,37 @@ DisplayTarget projectDisplay(String projectId) {
(todo) => write(todo, state, view.copyWith(layout: layout)), (todo) => write(todo, state, view.copyWith(layout: layout)),
), ),
), ),
// The Inbox's Calendar as an agenda or three days of hours, as
// Todoist's Display offers in the Inbox alone (the developer's
// review, 7 Nov 2026): a project's quick add from the hours would
// add to the Inbox.
if (view.layout == ProjectLayout.calendar &&
(state.projectOf(projectId)?.inbox ?? false))
ListTile(
key: calendarLayoutKey,
leading: const Icon(LucideIcons.columns3),
title: Text(l10n.layoutTitle),
subtitle: Text(calendarLayoutText(l10n, view.calendarLayout)),
onTap: () async {
if (await pickOne(
context,
l10n.layoutTitle,
CalendarLayout.values,
view.calendarLayout,
(layout) => calendarLayoutText(l10n, layout),
)
case final layout? when context.mounted) {
final now = context.read<TodoCubit>().state;
await apply(
(todo) => write(
todo,
now,
viewOf(now).copyWith(calendarLayout: layout),
),
);
}
},
),
if (view.layout == ProjectLayout.table) if (view.layout == ProjectLayout.table)
ListTile( ListTile(
leading: const Icon(LucideIcons.columns3), leading: const Icon(LucideIcons.columns3),
@ -122,6 +153,16 @@ DisplayTarget projectDisplay(String projectId) {
); );
} }
/// The key of the Inbox's Calendar's row choosing Agenda or 3-day.
const calendarLayoutKey = ValueKey('display-calendar-layout');
/// [layout]'s name, as Upcoming's Display names its own.
String calendarLayoutText(AppLocalizations l10n, CalendarLayout layout) =>
switch (layout) {
CalendarLayout.agenda => l10n.layoutAgenda,
CalendarLayout.threeDay => l10n.layoutThreeDay,
};
/// Upcoming's Display (D17, as Todoist's): agenda or 3-day, in the grid /// Upcoming's Display (D17, as Todoist's): agenda or 3-day, in the grid
/// what tasks are coloured by, done tasks, future occurrences, sorting /// what tasks are coloured by, done tasks, future occurrences, sorting
/// (smart: by time, then priority), and the filters by deadline, priority /// (smart: by time, then priority), and the filters by deadline, priority

View file

@ -4,11 +4,13 @@ import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.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.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart'; import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.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/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/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:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -19,7 +21,8 @@ import 'package:material_ui/material_ui.dart';
/// Display keeps, sub-tasks too, a day's in its sorting (by time where it /// Display keeps, sub-tasks too, a day's in its sorting (by time where it
/// sorts by hand); where it asks, repeating tasks also on their later days. /// sorts by hand); where it asks, repeating tasks also on their later days.
/// *No date* beside the month opens the tasks without one. From /// *No date* beside the month opens the tasks without one. From
/// [ProjectCalendarView.monthWidth] on, a month instead ([MonthGrid]). /// [ProjectCalendarView.monthWidth] on, a month instead ([MonthGrid]). The
/// Inbox's, where its Display asks, three days of hours ([HourGrid]).
class const ProjectCalendarView({ class const ProjectCalendarView({
required final String projectId, required final String projectId,
required final ProjectView view, required final ProjectView view,
@ -69,6 +72,23 @@ class const ProjectCalendarView({
child: Text(l10n.calendarNoDate), child: Text(l10n.calendarNoDate),
); );
final order = view.sorting == TaskSorting.manual ? byTime : view.compare; final order = view.sorting == TaskSorting.manual ? byTime : view.compare;
ProjectCalendar calendarFor(int days) => calendarOf(
kept,
Day.of(clock.now()),
days: days,
occurrences: view.occurrences,
order: order,
);
// The Inbox's three days of hours, as Upcoming's (the developer's
// review, 7 Nov 2026), whatever the width.
if (view.calendarLayout == CalendarLayout.threeDay &&
(state.projectOf(projectId)?.inbox ?? false)) {
return HourGrid(
foot: 0,
coloring: TaskColoring.priority,
calendarFor: calendarFor,
);
}
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) => constraints.maxWidth >= monthWidth builder: (context, constraints) => constraints.maxWidth >= monthWidth
? MonthGrid( ? MonthGrid(
@ -86,13 +106,7 @@ class const ProjectCalendarView({
: DayAgenda( : DayAgenda(
projectId: projectId, projectId: projectId,
trailing: noDate, trailing: noDate,
calendarFor: (days) => calendarOf( calendarFor: calendarFor,
kept,
Day.of(clock.now()),
days: days,
occurrences: view.occurrences,
order: order,
),
), ),
); );
} }

View file

@ -28,6 +28,7 @@ import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/month_grid.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar_view.dart';
@ -1585,6 +1586,87 @@ void main() {
expect(find.byTooltip('Mark as done'), findsOneWidget); expect(find.byTooltip('Mark as done'), findsOneWidget);
}); });
testWidgets("the Inbox's Calendar is an Agenda or 3-day, as its "
'Display picks, kept in the Inbox; another project offers '
"neither (the developer's review, 7 Nov 2026, as Todoist's)", (
tester,
) async {
await calendarOf(tester);
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(Day.of(DateTime.now()), time: 10 * 60),
),
);
expect(find.byType(WeekStrip), findsOneWidget);
await display(tester);
expect(
find.descendant(
of: find.byKey(calendarLayoutKey),
matching: find.text('Agenda'),
),
findsOneWidget,
);
await tester.tap(find.byKey(calendarLayoutKey));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, '3-day').last);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byKey(calendarLayoutKey),
matching: find.text('3-day'),
),
findsOneWidget,
);
await done(tester);
expect(find.byType(HourGrid), findsOneWidget);
expect(find.byType(WeekStrip), findsNothing);
expect(
find.descendant(
of: find.byType(HourGrid),
matching: find.text('Dentist'),
),
findsOneWidget,
);
final projects = await tester.runAsync(
() => ProjectRepository(projectRows, keys).all(),
);
expect(
projects!
.singleWhere((project) => project.id == inbox())
.content
?.view
.calendarLayout,
CalendarLayout.threeDay,
);
// A project of its own: its Calendar is the agenda alone.
await tester.runAsync(
() => ProjectRepository(projectRows, keys).save(
const ProjectContent(
name: 'Shopping',
position: 1,
view: ProjectView(
layout: ProjectLayout.calendar,
calendarLayout: CalendarLayout.threeDay,
),
),
),
);
projectRows.bringRemoteChanges();
await goTab(tester, 'Browse');
await tester.tap(find.text('Shopping'));
await tester.pumpAndSettle();
expect(find.byType(WeekStrip), findsOneWidget);
expect(find.byType(HourGrid), findsNothing);
await display(tester);
expect(find.byKey(calendarLayoutKey), findsNothing);
});
group('as a month on a wide window', () { group('as a month on a wide window', () {
/// Another day of today's month, so in its grid. /// Another day of today's month, so in its grid.
Day otherDay() { Day otherDay() {

View file

@ -61,6 +61,30 @@ void main() {
}); });
}); });
test(
"the Inbox's Calendar keeps its layout, Agenda or 3-day, written "
"only where it isn't the Agenda (the developer's review, 7 Nov 2026)",
() {
const threeDay = ProjectView(
layout: ProjectLayout.calendar,
calendarLayout: CalendarLayout.threeDay,
);
expect(encodeView(threeDay), {
'layout': 'calendar',
'calendarLayout': 'threeDay',
});
expect(decodeView(encodeView(threeDay)), threeDay);
expect(encodeView(const ProjectView(layout: ProjectLayout.calendar)), {
'layout': 'calendar',
});
expect(
decodeView({'layout': 'calendar', 'calendarLayout': 'week'}),
const ProjectView(layout: ProjectLayout.calendar),
reason: 'a layout this version does not know: the Agenda',
);
},
);
test('a Display value this version does not know reads as at first', () { test('a Display value this version does not know reads as at first', () {
final read = decodeProject( final read = decodeProject(
json({ json({