feat(todo): a project's Calendar, Upcoming's Display and its 3-day grid (ADR 0033, D17)

A project's Display gains the Calendar layout: Upcoming's week strip
and agenda, now shared as DayAgenda, over the project's tasks it keeps,
with No date beside the month and Future occurrences, each repeating
task also on the days it will come back (calendarOf on Repeat.next),
muted, neither ticked off nor dragged.

Upcoming gets a Display of its own, the same sheet over another target
(DisplayTarget): Agenda or 3-day, done tasks, future occurrences,
sorting, the filters by deadline, priority and label, and in the grid
Colouring by priority, label or project. It is kept in the person's
settings, one sealed row under an ID keyed by the account in the new
synced table todo_settings (migration settings: the table, its index
on spaceId and its foreign key to the space, cascading on delete).

The 3-day grid shows today and the two days after, stepped or swiped
three at a time: tasks without a time all day, timed ones as blocks as
long as they last, overlapping ones side by side (layOutDay). A long
press on a half hour adds a task there for half an hour; Overdue opens
the overdue tasks with Reschedule.

The board's separator builder names its BuildContext, as the root's
analyzer plugin asks (never_discard_build_context).

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-01 20:01:00 +01:00
commit 2939437c74
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
35 changed files with 21845 additions and 314 deletions

View file

@ -206,5 +206,19 @@
"layoutTitle": "Layout",
"layoutList": "Liste",
"layoutBoard": "Board",
"boardNoSection": "(Kein Abschnitt)"
"boardNoSection": "(Kein Abschnitt)",
"layoutCalendar": "Kalender",
"displayOccurrences": "Künftige Wiederholungen",
"calendarNoDate": "Ohne Datum",
"calendarNoDateEmpty": "Jede Aufgabe hier hat ein Datum",
"calendarNoDateHint": "Aufgaben dieses Projekts ohne Datum stehen hier.",
"taskLater": "Eine spätere Wiederholung",
"sortingSmart": "Smart",
"layoutAgenda": "Agenda",
"layoutThreeDay": "3 Tage",
"displayColoring": "Farben",
"coloringProject": "Projekt",
"allDay": "Ganztägig",
"daysPrevious": "Frühere Tage",
"daysNext": "Spätere Tage"
}

View file

@ -624,5 +624,61 @@
"boardNoSection": "(No section)",
"@boardNoSection": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"layoutCalendar": "Calendar",
"@layoutCalendar": {
"description": "A project's Calendar layout and its Display (ADR 0033, D17)."
},
"displayOccurrences": "Future occurrences",
"@displayOccurrences": {
"description": "A project's Calendar layout and its Display (ADR 0033, D17)."
},
"calendarNoDate": "No date",
"@calendarNoDate": {
"description": "A project's Calendar layout and its Display (ADR 0033, D17)."
},
"calendarNoDateEmpty": "Every task here has a date",
"@calendarNoDateEmpty": {
"description": "A project's Calendar layout and its Display (ADR 0033, D17)."
},
"calendarNoDateHint": "Tasks of this project without a date show here.",
"@calendarNoDateHint": {
"description": "A project's Calendar layout and its Display (ADR 0033, D17)."
},
"taskLater": "A later time of a repeating task",
"@taskLater": {
"description": "A project's Calendar layout and its Display (ADR 0033, D17)."
},
"sortingSmart": "Smart",
"@sortingSmart": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"layoutAgenda": "Agenda",
"@layoutAgenda": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"layoutThreeDay": "3-day",
"@layoutThreeDay": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"displayColoring": "Colouring",
"@displayColoring": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"coloringProject": "Project",
"@coloringProject": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"allDay": "All day",
"@allDay": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"daysPrevious": "Earlier days",
"@daysPrevious": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
},
"daysNext": "Later days",
"@daysNext": {
"description": "Upcoming's Display and its 3-day grid (ADR 0033, D17)."
}
}

View file

@ -1341,6 +1341,90 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'(No section)'**
String get boardNoSection;
/// A project's Calendar layout and its Display (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Calendar'**
String get layoutCalendar;
/// A project's Calendar layout and its Display (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Future occurrences'**
String get displayOccurrences;
/// A project's Calendar layout and its Display (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'No date'**
String get calendarNoDate;
/// A project's Calendar layout and its Display (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Every task here has a date'**
String get calendarNoDateEmpty;
/// A project's Calendar layout and its Display (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Tasks of this project without a date show here.'**
String get calendarNoDateHint;
/// A project's Calendar layout and its Display (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'A later time of a repeating task'**
String get taskLater;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Smart'**
String get sortingSmart;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Agenda'**
String get layoutAgenda;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'3-day'**
String get layoutThreeDay;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Colouring'**
String get displayColoring;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Project'**
String get coloringProject;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'All day'**
String get allDay;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Earlier days'**
String get daysPrevious;
/// Upcoming's Display and its 3-day grid (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Later days'**
String get daysNext;
}
class _AppLocalizationsDelegate

View file

@ -833,4 +833,47 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get boardNoSection => '(Kein Abschnitt)';
@override
String get layoutCalendar => 'Kalender';
@override
String get displayOccurrences => 'Künftige Wiederholungen';
@override
String get calendarNoDate => 'Ohne Datum';
@override
String get calendarNoDateEmpty => 'Jede Aufgabe hier hat ein Datum';
@override
String get calendarNoDateHint =>
'Aufgaben dieses Projekts ohne Datum stehen hier.';
@override
String get taskLater => 'Eine spätere Wiederholung';
@override
String get sortingSmart => 'Smart';
@override
String get layoutAgenda => 'Agenda';
@override
String get layoutThreeDay => '3 Tage';
@override
String get displayColoring => 'Farben';
@override
String get coloringProject => 'Projekt';
@override
String get allDay => 'Ganztägig';
@override
String get daysPrevious => 'Frühere Tage';
@override
String get daysNext => 'Spätere Tage';
}

View file

@ -832,6 +832,49 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get boardNoSection => '(No section)';
@override
String get layoutCalendar => 'Calendar';
@override
String get displayOccurrences => 'Future occurrences';
@override
String get calendarNoDate => 'No date';
@override
String get calendarNoDateEmpty => 'Every task here has a date';
@override
String get calendarNoDateHint =>
'Tasks of this project without a date show here.';
@override
String get taskLater => 'A later time of a repeating task';
@override
String get sortingSmart => 'Smart';
@override
String get layoutAgenda => 'Agenda';
@override
String get layoutThreeDay => '3-day';
@override
String get displayColoring => 'Colouring';
@override
String get coloringProject => 'Project';
@override
String get allDay => 'All day';
@override
String get daysPrevious => 'Earlier days';
@override
String get daysNext => 'Later days';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -17,6 +17,8 @@ import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.
import 'package:halkyon_todo_flutter/src/feature/reminder/data/system_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/sync_section_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -27,7 +29,7 @@ import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// A signed-in person's dependencies: who they are, their projects, sections,
/// tasks and labels, on
/// tasks, labels and settings, on
/// their own local database in their folder, which the sync engine keeps in
/// step with their other devices, every row sealed with their key (ADR 0033,
/// D5, D6). halkyon_app's `PersonScope` opens and disposes them with the
@ -51,6 +53,9 @@ class TodoPerson(
/// Their labels, sealed and synced.
required final LabelRepository labels,
/// Their settings of Todo's (Upcoming's Display), sealed and synced.
required final SettingsRepository settings,
/// The device's notifications, where their tasks' reminders go (ADR
/// 0033, D5); none in tests that don't say.
final ReminderGateway reminders = const NoReminders(),
@ -318,6 +323,7 @@ Future<TodoPerson> _openPersonData(
final sections = SectionRepository(SyncSectionGateway(db, sync), keys);
final tasks = TaskRepository(SyncTaskGateway(db, sync), keys);
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
final settings = SettingsRepository(SyncSettingsGateway(db, sync), keys);
// The Inbox, made once by whichever device is first; nothing where this
// device holds none of the account's keys.
unawaited(
@ -334,6 +340,7 @@ Future<TodoPerson> _openPersonData(
sections: sections,
tasks: tasks,
labels: labels,
settings: settings,
reminders: reminders,
onDispose: () async {
try {
@ -341,6 +348,7 @@ Future<TodoPerson> _openPersonData(
await sections.dispose();
await tasks.dispose();
await labels.dispose();
await settings.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -17,13 +17,14 @@ ByteData encodeProject(ProjectContent content) =>
'color': ?content.color?.name,
'position': content.position,
// As at first, nothing: a project's Display is written once set (D17).
if (!content.view.isDefault) 'view': _encodeView(content.view),
if (!content.view.isDefault) 'view': encodeView(content.view),
});
/// [view] by its values' names, those left as at first left out.
Map<String, Object?> _encodeView(ProjectView view) => {
Map<String, Object?> encodeView(ProjectView view) => {
if (view.layout != ProjectLayout.list) 'layout': view.layout.name,
if (view.completed) 'completed': true,
if (view.occurrences) 'occurrences': true,
if (view.grouping != TaskGrouping.none) 'grouping': view.grouping.name,
if (view.sorting != TaskSorting.manual) 'sorting': view.sorting.name,
if (view.date != DayRange.all) 'date': view.date.name,
@ -34,7 +35,7 @@ Map<String, Object?> _encodeView(ProjectView view) => {
};
/// The view [json] holds; a value this version doesn't know, as at first.
ProjectView _decodeView(Object? json) {
ProjectView decodeView(Object? json) {
if (json is! Map<String, Object?>) {
return const ProjectView();
}
@ -44,6 +45,7 @@ ProjectView _decodeView(Object? json) {
return ProjectView(
layout: named(ProjectLayout.values, 'layout', ProjectLayout.list),
completed: json['completed'] == true,
occurrences: json['occurrences'] == true,
grouping: named(TaskGrouping.values, 'grouping', TaskGrouping.none),
sorting: named(TaskSorting.values, 'sorting', TaskSorting.manual),
date: named(DayRange.values, 'date', DayRange.all),
@ -69,6 +71,6 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
name: json['name']! as String,
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
view: _decodeView(json['view']),
view: decodeView(json['view']),
),
);

View file

@ -0,0 +1,95 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// A row of a project's Calendar (ADR 0033, D17): a task on its own day,
/// or, where `occurrence`, a later time of a repeating one, the task as it
/// will be then (its ID kept, its date moved).
typedef CalendarEntry = ({OpenTask task, bool occurrence});
/// What a project's Calendar shows (D17): the `overdue` tasks, then each of
/// its `days` with its rows; the days with open tasks or later times are
/// `busy`, marked in the week strip.
typedef ProjectCalendar = ({
List<OpenTask> overdue,
List<(Day, List<CalendarEntry>)> days,
Set<Day> busy,
});
/// How many later times of one task at most, so that a daily one doesn't
/// fill a year picked far ahead.
const _occurrencesAtMost = 400;
/// [tasks] (those the project's Display keeps, done ones only where it
/// shows them) as a Calendar from [today] on for [days] days (D17): as
/// Upcoming's, overdue first, a day's tasks by time or [order]; where
/// [occurrences], each repeating task not done also on the days it will
/// come back, the first as if done today (`Repeat.next`), until its
/// recurrence ends.
ProjectCalendar calendarOf(
List<OpenTask> tasks,
Day today, {
required int days,
bool occurrences = false,
int Function(OpenTask a, OpenTask b) order = byTime,
}) {
final (:overdue, days: listed) = upcomingOf(
tasks,
today,
days: days,
completed: true,
);
final byDay = {
for (final (day, dayTasks) in listed)
day: <CalendarEntry>[
for (final task in dayTasks) (task: task, occurrence: false),
],
};
final busy = {
for (final task in tasks)
if (task.content case TaskContent(
completed: false,
date: TaskDate(:final day),
))
day,
};
if (occurrences) {
final end = today.plus(days);
for (final task in tasks) {
final content = task.content;
if (content.completed) {
continue;
}
if (content.date case final date? when date.repeat != null) {
var repeat = date.repeat!;
var next = repeat.next(date.day, today: today);
for (
var count = 0;
next != null && next < end && count < _occurrencesAtMost;
count++
) {
final day = next;
repeat = repeat.counted();
byDay[day]?.add((
task: (
id: task.id,
content: content.copyWith(
date: () => date.on(day).repeating(repeat),
),
),
occurrence: true,
));
busy.add(day);
next = repeat.next(day, today: day);
}
}
}
}
return (
overdue: overdue,
days: [
for (final MapEntry(:key, :value) in byDay.entries)
(key, value..sort((a, b) => order(a.task, b.task))),
],
busy: busy,
);
}

View file

@ -63,12 +63,16 @@ enum DayRange() {
}
/// A project's Display (ADR 0033, D17, as Todoist's): its [layout], whether
/// it shows its done tasks, how it groups and sorts them, and the tasks it
/// it shows its done tasks (and, as a Calendar, the later times of
/// repeating ones), how it groups and sorts them, and the tasks it
/// keeps, by date, deadline, priority and label. Kept in the project's
/// sealed row, so every device of the person's shows the project alike.
final class const ProjectView({
final ProjectLayout layout = ProjectLayout.list,
final bool completed = false,
/// Whether a Calendar shows repeating tasks on their later days too.
final bool occurrences = false,
final TaskGrouping grouping = TaskGrouping.none,
final TaskSorting sorting = TaskSorting.manual,
final DayRange date = DayRange.all,
@ -140,6 +144,7 @@ final class const ProjectView({
ProjectView copyWith({
ProjectLayout? layout,
bool? completed,
bool? occurrences,
TaskGrouping? grouping,
TaskSorting? sorting,
DayRange? date,
@ -149,6 +154,7 @@ final class const ProjectView({
}) => ProjectView(
layout: layout ?? this.layout,
completed: completed ?? this.completed,
occurrences: occurrences ?? this.occurrences,
grouping: grouping ?? this.grouping,
sorting: sorting ?? this.sorting,
date: date ?? this.date,
@ -161,6 +167,7 @@ final class const ProjectView({
List<Object?> get props => [
layout,
completed,
occurrences,
grouping,
sorting,
date,

View file

@ -4,6 +4,7 @@ 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/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.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/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
@ -12,52 +13,204 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The button opening a project's Display (ADR 0033, D17), a dot on it
/// while the project shows otherwise than at first.
class const DisplayButton({required final String projectId, super.key})
/// What a Display sheet sets (ADR 0033, D17): a project's view, or
/// Upcoming's in the person's settings. [viewOf] reads its done tasks,
/// future occurrences, sorting and filters, [write] keeps them, [layout]
/// builds the rows above them (the layout's picker and what goes with
/// it), and [reset] puts it all back as at first, which [isDefault] says
/// it is.
final class const DisplayTarget({
required final ProjectView Function(TodoState state) viewOf,
required final Future<void> Function(
TodoPerson todo,
TodoState state,
ProjectView view,
)
write,
required final bool Function(TodoState state) isDefault,
required final Future<void> Function(TodoPerson todo, TodoState state) reset,
required final List<Widget> Function(
BuildContext context,
TodoState state,
Future<void> Function(Future<void> Function(TodoPerson todo) change) apply,
)
layout,
/// Whether it groups (a project's list and board).
final bool Function(TodoState state) grouping = _never,
/// Whether it filters by date (a project; Upcoming is days already).
final bool dateFilter = true,
/// Whether it shows repeating tasks' later times (a Calendar, Upcoming).
final bool Function(TodoState state) occurrences = _never,
/// The sortings it offers; manual first, named [manualText].
final List<TaskSorting> sortings = TaskSorting.values,
final String Function(AppLocalizations l10n)? manualText,
});
bool _never(TodoState state) => false;
/// A project's Display (D17): its layout (list, board, calendar), done
/// tasks, grouping but in a Calendar, future occurrences in one, sorting,
/// filters; kept in the project's sealed row.
DisplayTarget projectDisplay(String projectId) {
ProjectView viewOf(TodoState state) =>
state.projectOf(projectId)?.content?.view ?? const ProjectView();
Future<void> write(TodoPerson todo, TodoState state, ProjectView view) async {
if (state.projectOf(projectId)?.content case final content?) {
await todo.projects.update(projectId, content.copyWith(view: view));
}
}
bool calendar(TodoState state) =>
viewOf(state).layout == ProjectLayout.calendar;
return DisplayTarget(
viewOf: viewOf,
write: write,
isDefault: (state) => viewOf(state).isDefault,
reset: (todo, state) => write(todo, state, const ProjectView()),
grouping: (state) => !calendar(state),
occurrences: calendar,
layout: (context, state, apply) {
final l10n = AppLocalizations.of(context);
final view = viewOf(state);
return [
_LayoutPicker<ProjectLayout>(
selected: view.layout,
segments: [
(ProjectLayout.list, LucideIcons.list, l10n.layoutList),
(ProjectLayout.board, LucideIcons.squareKanban, l10n.layoutBoard),
(
ProjectLayout.calendar,
LucideIcons.calendarDays,
l10n.layoutCalendar,
),
],
onPicked: (layout) => apply(
(todo) => write(todo, state, view.copyWith(layout: layout)),
),
),
];
},
);
}
/// Upcoming's Display (D17, as Todoist's): agenda or 3-day, in the grid
/// what tasks are coloured by, done tasks, future occurrences, sorting
/// (smart: by time, then priority), and the filters by deadline, priority
/// and label; kept in the person's settings.
final upcomingDisplay = DisplayTarget(
viewOf: (state) => state.settings.upcoming.filters,
write: _writeUpcoming,
isDefault: (state) => state.settings.upcoming.isDefault,
reset: (todo, state) => todo.settings.write(
state.settings.copyWith(upcoming: const UpcomingView()),
),
dateFilter: false,
occurrences: (_) => true,
sortings: const [
TaskSorting.manual,
TaskSorting.name,
TaskSorting.deadline,
TaskSorting.addedDate,
TaskSorting.priority,
],
manualText: (l10n) => l10n.sortingSmart,
layout: (context, state, apply) {
final l10n = AppLocalizations.of(context);
final upcoming = state.settings.upcoming;
Future<void> set(UpcomingView view) => apply(
(todo) => todo.settings.write(state.settings.copyWith(upcoming: view)),
);
return [
_LayoutPicker<UpcomingLayout>(
selected: upcoming.layout,
segments: [
(UpcomingLayout.agenda, LucideIcons.list, l10n.layoutAgenda),
(UpcomingLayout.threeDay, LucideIcons.columns3, l10n.layoutThreeDay),
],
onPicked: (layout) => set(upcoming.copyWith(layout: layout)),
),
if (upcoming.layout == UpcomingLayout.threeDay)
ListTile(
leading: const Icon(LucideIcons.palette),
title: Text(l10n.displayColoring),
subtitle: Text(coloringText(l10n, upcoming.coloring)),
onTap: () async {
if (await _pickOne(
context,
l10n.displayColoring,
TaskColoring.values,
upcoming.coloring,
(coloring) => coloringText(l10n, coloring),
)
case final coloring?) {
await set(upcoming.copyWith(coloring: coloring));
}
},
),
];
},
);
Future<void> _writeUpcoming(
TodoPerson todo,
TodoState state,
ProjectView view,
) => todo.settings.write(
state.settings.copyWith(
upcoming: state.settings.upcoming.copyWith(filters: view),
),
);
/// The button opening [target]'s Display (ADR 0033, D17), a dot on it
/// while anything differs from how it shows at first.
class const DisplayButton({required final DisplayTarget target, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final view = context.select<TodoCubit, ProjectView>(
(cubit) =>
cubit.state.projectOf(projectId)?.content?.view ??
const ProjectView(),
final changed = context.select<TodoCubit, bool>(
(cubit) => !target.isDefault(cubit.state),
);
return IconButton(
tooltip: AppLocalizations.of(context).display,
icon: Badge(
smallSize: 8,
isLabelVisible: !view.isDefault,
isLabelVisible: changed,
child: const Icon(LucideIcons.slidersHorizontal),
),
onPressed: () => showDisplaySheet(context, projectId),
onPressed: () => showDisplaySheet(context, target),
);
}
}
/// A project's Display (D17, as Todoist's): its done tasks shown or not,
/// how it groups and sorts, and what it filters by date, deadline, priority
/// and label; each change written to the project at once, synced to every
/// [target]'s Display (D17, as Todoist's): its layout, done tasks shown or
/// not, how it groups and sorts, and what it filters by date, deadline,
/// priority and label; each change written at once, synced to every
/// device, Reset all putting it back as at first.
Future<void> showDisplaySheet(BuildContext context, String projectId) =>
Future<void> showDisplaySheet(BuildContext context, DisplayTarget target) =>
showAdaptiveSheet<void>(
context,
builder: (context) => _DisplaySheet(projectId: projectId),
builder: (context) => _DisplaySheet(target: target),
);
class const _DisplaySheet({required final String projectId})
class const _DisplaySheet({required final DisplayTarget target})
extends StatelessWidget {
Future<void> _set(BuildContext context, ProjectView view) async {
/// Runs [change] with the person's data, saying so where this device
/// can't seal.
Future<void> _apply(
BuildContext context,
Future<void> Function(TodoPerson todo) change,
) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final project = context.read<TodoCubit>().state.projectOf(projectId);
if (project?.content case final content?) {
try {
await todo.projects.update(projectId, content.copyWith(view: view));
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
try {
await change(todo);
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
}
@ -66,8 +219,15 @@ class const _DisplaySheet({required final String projectId})
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final view =
state.projectOf(projectId)?.content?.view ?? const ProjectView();
final view = target.viewOf(state);
Future<void> set(ProjectView view) =>
_apply(context, (todo) => target.write(todo, state, view));
Future<void> apply(Future<void> Function(TodoPerson todo) change) =>
_apply(context, change);
String sortingName(TaskSorting sorting) =>
sorting == TaskSorting.manual && target.manualText != null
? target.manualText!(l10n)
: sortingText(l10n, sorting);
final labels = {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
@ -99,99 +259,75 @@ class const _DisplaySheet({required final String projectId})
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(l10n.layoutTitle),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<ProjectLayout>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: ProjectLayout.list,
icon: const Icon(LucideIcons.list),
label: Text(l10n.layoutList),
),
ButtonSegment(
value: ProjectLayout.board,
icon: const Icon(LucideIcons.squareKanban),
label: Text(l10n.layoutBoard),
),
],
// The calendar comes with its own step (D17); until then a
// project laid out so shows as a list.
selected: {
if (view.layout == ProjectLayout.board)
ProjectLayout.board
else
ProjectLayout.list,
},
onSelectionChanged: (picked) =>
_set(context, view.copyWith(layout: picked.single)),
),
),
...target.layout(context, state, apply),
SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck),
title: Text(l10n.displayCompleted),
value: view.completed,
onChanged: (on) => _set(context, view.copyWith(completed: on)),
onChanged: (on) => set(view.copyWith(completed: on)),
),
if (target.occurrences(state))
SwitchListTile(
secondary: const Icon(LucideIcons.repeat),
title: Text(l10n.displayOccurrences),
value: view.occurrences,
onChanged: (on) => set(view.copyWith(occurrences: on)),
),
heading(l10n.displaySort),
choice(
LucideIcons.group,
l10n.displayGrouping,
groupingText(l10n, view.grouping),
() async {
if (await _pickOne(
context,
l10n.displayGrouping,
TaskGrouping.values,
view.grouping,
(grouping) => groupingText(l10n, grouping),
)
case final grouping?) {
if (context.mounted) {
await _set(context, view.copyWith(grouping: grouping));
if (target.grouping(state))
choice(
LucideIcons.group,
l10n.displayGrouping,
groupingText(l10n, view.grouping),
() async {
if (await _pickOne(
context,
l10n.displayGrouping,
TaskGrouping.values,
view.grouping,
(grouping) => groupingText(l10n, grouping),
)
case final grouping?) {
await set(view.copyWith(grouping: grouping));
}
}
},
),
},
),
choice(
LucideIcons.arrowUpDown,
l10n.displaySorting,
sortingText(l10n, view.sorting),
sortingName(view.sorting),
() async {
if (await _pickOne(
context,
l10n.displaySorting,
TaskSorting.values,
target.sortings,
view.sorting,
(sorting) => sortingText(l10n, sorting),
sortingName,
)
case final sorting?) {
if (context.mounted) {
await _set(context, view.copyWith(sorting: sorting));
}
await set(view.copyWith(sorting: sorting));
}
},
),
heading(l10n.displayFilter),
choice(
LucideIcons.calendar,
l10n.dateTitle,
rangeText(l10n, view.date),
() async {
if (await _pickOne(
context,
l10n.dateTitle,
DayRange.values,
view.date,
(range) => rangeText(l10n, range),
)
case final range?) {
if (context.mounted) {
await _set(context, view.copyWith(date: range));
if (target.dateFilter)
choice(
LucideIcons.calendar,
l10n.dateTitle,
rangeText(l10n, view.date),
() async {
if (await _pickOne(
context,
l10n.dateTitle,
DayRange.values,
view.date,
(range) => rangeText(l10n, range),
)
case final range?) {
await set(view.copyWith(date: range));
}
}
},
),
},
),
choice(
LucideIcons.target,
l10n.displayDeadline,
@ -205,9 +341,7 @@ class const _DisplaySheet({required final String projectId})
(range) => rangeText(l10n, range, deadline: true),
)
case final range?) {
if (context.mounted) {
await _set(context, view.copyWith(deadline: range));
}
await set(view.copyWith(deadline: range));
}
},
),
@ -224,9 +358,7 @@ class const _DisplaySheet({required final String projectId})
() async {
if (await _pickPriorities(context, view.priorities)
case final picked?) {
if (context.mounted) {
await _set(context, view.copyWith(priorities: picked));
}
await set(view.copyWith(priorities: picked));
}
},
),
@ -241,9 +373,7 @@ class const _DisplaySheet({required final String projectId})
() async {
if (await pickLabels(context, [...view.labelIds])
case final picked?) {
if (context.mounted) {
await _set(context, view.copyWith(labelIds: {...picked}));
}
await set(view.copyWith(labelIds: {...picked}));
}
},
),
@ -252,9 +382,9 @@ class const _DisplaySheet({required final String projectId})
footer: Row(
children: [
TextButton(
onPressed: view.isDefault
onPressed: target.isDefault(state)
? null
: () => _set(context, const ProjectView()),
: () => apply((todo) => target.reset(todo, state)),
child: Text(l10n.displayReset),
),
const Spacer(),
@ -268,6 +398,27 @@ class const _DisplaySheet({required final String projectId})
}
}
/// A Display's layouts side by side, [selected] marked.
class const _LayoutPicker<T>({
required final T selected,
required final List<(T, IconData, String)> segments,
required final ValueChanged<T> onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<T>(
showSelectedIcon: false,
segments: [
for (final (value, icon, label) in segments)
ButtonSegment(value: value, icon: Icon(icon), label: Text(label)),
],
selected: {selected},
onSelectionChanged: (picked) => onPicked(picked.single),
),
);
}
/// Asks for one of [values], [current] ticked.
Future<T?> _pickOne<T>(
BuildContext context,
@ -362,6 +513,14 @@ String sortingText(AppLocalizations l10n, TaskSorting sorting) =>
TaskSorting.priority => l10n.sortingPriority,
};
/// [coloring]'s name.
String coloringText(AppLocalizations l10n, TaskColoring coloring) =>
switch (coloring) {
TaskColoring.priority => l10n.priorityTitle,
TaskColoring.label => l10n.displayLabel,
TaskColoring.project => l10n.coloringProject,
};
/// [range]'s name, for a filter by date or, where [deadline], by deadline.
String rangeText(
AppLocalizations l10n,

View file

@ -102,7 +102,7 @@ class _ProjectBoardState() extends State<ProjectBoard> {
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(width: 12),
separatorBuilder: (context, index) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
SizedBox(width: 320, child: column(index)),
);

View file

@ -0,0 +1,113 @@
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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_view.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/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/day_agenda.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A project's tasks as a Calendar (ADR 0033, D17, as Todoist's): Upcoming's
/// week strip and agenda ([DayAgenda]) over the tasks of [projectId] its
/// 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.
/// *No date* beside the month opens the tasks without one.
class const ProjectCalendarView({
required final String projectId,
required final ProjectView view,
super.key,
}) extends StatelessWidget {
/// The key of the button opening the tasks without a date.
static const noDateKey = ValueKey('calendar-no-date');
/// The project's tasks the Display keeps, done ones where it shows them.
static List<OpenTask> keptOf(
BuildContext context,
TodoState state,
String projectId,
ProjectView view,
) {
final today = Day.of(clock.now());
final weekStart = WeekStrip.weekOf(
today,
MaterialLocalizations.of(context).firstDayOfWeekIndex,
);
return [
for (final task in state.tasks)
if (task.content.projectId == projectId &&
(view.completed || !task.content.completed) &&
view.shows(task.content, today: today, weekStart: weekStart))
task,
];
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final kept = keptOf(context, state, projectId, view);
return DayAgenda(
projectId: projectId,
trailing: TextButton(
key: noDateKey,
onPressed: () => _showNoDate(context, projectId, view),
child: Text(l10n.calendarNoDate),
),
calendarFor: (days) => calendarOf(
kept,
Day.of(clock.now()),
days: days,
occurrences: view.occurrences,
order: view.sorting == TaskSorting.manual ? byTime : view.compare,
),
);
}
}
/// The tasks of [projectId] without a date that [view] keeps, in its
/// order, in a sheet; a row swiped left gives one a date (D14).
Future<void> _showNoDate(
BuildContext context,
String projectId,
ProjectView view,
) => showAdaptiveSheet<void>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final undated = [
for (final task in ProjectCalendarView.keptOf(
context,
state,
projectId,
view,
))
if (task.content.date == null) task,
]..sort(view.compare);
return SheetScaffold(
title: l10n.calendarNoDate,
body: undated.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: EmptyListMessage(
icon: LucideIcons.calendarCheck,
title: l10n.calendarNoDateEmpty,
hint: l10n.calendarNoDateHint,
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final task in undated) TaskTile(task: task),
],
),
);
},
);

View file

@ -31,7 +31,7 @@ class const ProjectScreenBody({
title: title,
actions: [
if (projectId case final projectId?) ...[
DisplayButton(projectId: projectId),
DisplayButton(target: projectDisplay(projectId)),
PopupMenuButton<VoidCallback>(
tooltip: l10n.projectMenu,
onSelected: (action) => action(),

View file

@ -8,6 +8,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.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_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
@ -49,6 +50,9 @@ class const ProjectTasks({required final String projectId, super.key})
}
final view =
state.projectOf(projectId)?.content?.view ?? const ProjectView();
if (view.layout == ProjectLayout.calendar) {
return ProjectCalendarView(projectId: projectId, view: view);
}
final today = Day.of(clock.now());
final weekStart = WeekStrip.weekOf(
today,

View file

@ -0,0 +1,46 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
/// The schema of the settings' payload this app writes and reads.
const settingsSchemaVersion = 1;
/// [settings] as their row's payload, before sealing: only what differs
/// from how Todo starts, by names.
ByteData encodeSettings(TodoSettings settings) =>
encodeJson(settingsSchemaVersion, {
if (!settings.upcoming.isDefault)
'upcoming': _encodeUpcoming(settings.upcoming),
});
Map<String, Object?> _encodeUpcoming(UpcomingView view) => {
if (view.layout != UpcomingLayout.agenda) 'layout': view.layout.name,
if (view.coloring != TaskColoring.priority) 'coloring': view.coloring.name,
...encodeView(view.filters),
};
/// The settings [payload] holds, opened; null for a newer schema or one it
/// can't read. A value this version doesn't know reads as at first.
TodoSettings? decodeSettings(ByteData payload) => decodeJson(
payload,
version: settingsSchemaVersion,
name: 'Settings',
read: (json) => TodoSettings(upcoming: _decodeUpcoming(json['upcoming'])),
);
UpcomingView _decodeUpcoming(Object? json) {
if (json is! Map<String, Object?>) {
return const UpcomingView();
}
return UpcomingView(
layout:
UpcomingLayout.values.asNameMap()[json['layout']] ??
UpcomingLayout.agenda,
coloring:
TaskColoring.values.asNameMap()[json['coloring']] ??
TaskColoring.priority,
filters: decodeView(json),
);
}

View file

@ -0,0 +1,53 @@
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
/// The person's settings of Todo's (ADR 0033, D17): one row, sealed with
/// their key for its row (D5, [SealedRows]), under an ID keyed by the
/// account as the Inbox's, so every device of theirs writes the same one,
/// made the first time anything is set. None kept, or one that doesn't
/// open here, reads as at first.
class SettingsRepository(
final SealedRowGateway _gateway,
final TodoKeys _keys,
) {
late final _rows = SealedRows<TodoSettings>(
_gateway,
_keys,
table: 'todo_settings',
encode: encodeSettings,
decode: decodeSettings,
);
/// The row's ID; throws [KeysMissing] without the account's keys.
String get id => _rows.keys.rowId('todo/settings');
/// Fires when the settings changed, here or on another device.
Stream<void> get changes => _rows.changes;
/// The settings as kept, or as at first.
Future<TodoSettings> read() async {
final rows = await _rows.all();
if (!_rows.keys.unlocked) {
return const TodoSettings();
}
final id = this.id;
return rows.where((row) => row.id == id).firstOrNull?.content ??
const TodoSettings();
}
/// Keeps [settings]; throws [KeysMissing] where this device can't seal.
Future<void> write(TodoSettings settings) async {
final id = this.id;
if (await _rows.has(id)) {
await _rows.update(id, settings);
} else {
await _rows.insert(settings, id: id);
}
}
/// Closes what it holds open.
Future<void> dispose() => _rows.dispose();
}

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// The person's settings row on their local database ([SealedRowGateway]),
/// which the sync engine keeps in step with their other devices.
class SyncSettingsGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements SealedRowGateway {
@override
Future<List<SealedRow>> rows() async => [
for (final row in await protocol.TodoSettings.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<void> insert(SealedRow row) async {
await protocol.TodoSettings.db.insertRow(
_db,
protocol.TodoSettings(
id: protocol.UuidValue.withValidation(row.id),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.TodoSettings.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.TodoSettings.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(Iterable<String> ids) async {
final uuids = <protocol.UuidValue>{
for (final id in ids) protocol.UuidValue.withValidation(id),
};
if (uuids.isEmpty) {
return;
}
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
for (final row in await protocol.TodoSettings.db.find(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
)) {
await protocol.TodoSettings.db.updateRow(
_db,
protocol.TodoSettings(
id: row.id,
spaceId: row.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.TodoSettings.db.deleteWhere(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,57 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
/// How Upcoming lays out its days (ADR 0033, D17, as Todoist's).
enum UpcomingLayout() {
/// Day under day, as a list.
agenda,
/// Three days side by side, their hours down the screen.
threeDay,
}
/// What a task's block in the 3-day grid is coloured by (D17).
enum TaskColoring() {
priority,
label,
project,
}
/// Upcoming's Display (D17): its [layout], what the 3-day grid colours
/// tasks by, and the rest as a project's ([filters]: done tasks, future
/// occurrences, sorting, and the filters by deadline, priority and label;
/// its date filter, grouping and layout unused, the days being Upcoming's).
final class const UpcomingView({
final UpcomingLayout layout = UpcomingLayout.agenda,
final TaskColoring coloring = TaskColoring.priority,
final ProjectView filters = const ProjectView(),
}) with Equatable {
/// Whether nothing is changed from how Upcoming shows at first.
bool get isDefault => this == const UpcomingView();
UpcomingView copyWith({
UpcomingLayout? layout,
TaskColoring? coloring,
ProjectView? filters,
}) => UpcomingView(
layout: layout ?? this.layout,
coloring: coloring ?? this.coloring,
filters: filters ?? this.filters,
);
@override
List<Object?> get props => [layout, coloring, filters];
}
/// The person's settings of Todo's (D17): one sealed row of theirs, synced
/// to every device (`todo_settings`); for now [upcoming]'s Display, D4's
/// settings of tasks and reminders to join it.
final class const TodoSettings({
final UpcomingView upcoming = const UpcomingView(),
}) with Equatable {
TodoSettings copyWith({UpcomingView? upcoming}) =>
TodoSettings(upcoming: upcoming ?? this.upcoming);
@override
List<Object?> get props => [upcoming];
}

View file

@ -8,11 +8,13 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// Everything of the person's that Todo's screens show, as it stands on
/// this device (ADR 0033, D8): their projects (the Inbox first), sections,
/// tasks and labels, those that open; and how many rows of each kind don't.
/// tasks and labels, those that open, and their settings; and how many rows
/// of each kind don't.
final class const TodoState({
final bool loaded = false,
final List<StoredProject> projects = const [],
@ -20,6 +22,9 @@ final class const TodoState({
final List<OpenTask> tasks = const [],
final List<StoredLabel> labels = const [],
/// Their settings of Todo's, as at first until read.
final TodoSettings settings = const TodoSettings(),
/// How many rows don't open on this device: another device of the
/// person's wrote them before this one held the keys, or the instance
/// made them up.
@ -53,6 +58,7 @@ final class const TodoState({
sections,
tasks,
labels,
settings,
locked,
];
}
@ -76,6 +82,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
_todo.sections.changes,
_todo.tasks.changes,
_todo.labels.changes,
_todo.settings.changes,
]) {
_subscriptions.add(changes.listen((_) => unawaited(_reads.run())));
}
@ -87,6 +94,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
final sections = await _todo.sections.all();
final tasks = await _todo.tasks.all();
final labels = await _todo.labels.all();
final settings = await _todo.settings.read();
if (isClosed) {
return;
}
@ -103,6 +111,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
for (final label in labels)
if (label.content != null) label,
],
settings: settings,
locked: [
...projects.map((row) => row.content),
...sections.map((row) => row.content),

View file

@ -0,0 +1,78 @@
import 'dart:math';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
/// A timed task's block in a day of the 3-day grid (ADR 0033, D17): from
/// `start` to `end` (minutes after midnight), in `lane` of the `lanes` its
/// overlapping blocks share the day's width with.
typedef GridBlock = ({
CalendarEntry entry,
int start,
int end,
int lane,
int lanes,
});
/// The shortest a block is drawn: a task without a duration is half an
/// hour, as the slot a long press picks.
const minBlockMinutes = 30;
/// A day's [entries] as the grid shows them: those without a time
/// `allDay`, in their order; the rest as `blocks` by when they start
/// (longer first on a tie), each lasting its duration, half an hour at
/// least, until midnight at most. Blocks that overlap, directly or through
/// others, share the width: each takes the first lane free when it starts.
({List<CalendarEntry> allDay, List<GridBlock> blocks}) layOutDay(
List<CalendarEntry> entries,
) {
final allDay = <CalendarEntry>[];
final timed = <(CalendarEntry, int, int)>[];
for (final entry in entries) {
final date = entry.task.content.date;
if (date?.time case final time?) {
final minutes = max(date!.duration?.inMinutes ?? 0, minBlockMinutes);
timed.add((entry, time, min(time + minutes, 24 * 60)));
} else {
allDay.add(entry);
}
}
timed.sort((a, b) {
final byStart = a.$2.compareTo(b.$2);
return byStart != 0 ? byStart : (b.$3 - b.$2).compareTo(a.$3 - a.$2);
});
final blocks = <GridBlock>[];
// The blocks of the cluster being laid out, and where each lane ends.
var cluster = <(CalendarEntry, int, int, int)>[];
var laneEnds = <int>[];
var clusterEnd = -1;
void close() {
for (final (entry, start, end, lane) in cluster) {
blocks.add((
entry: entry,
start: start,
end: end,
lane: lane,
lanes: laneEnds.length,
));
}
cluster = [];
laneEnds = [];
}
for (final (entry, start, end) in timed) {
if (start >= clusterEnd) {
close();
}
var lane = laneEnds.indexWhere((laneEnd) => laneEnd <= start);
if (lane == -1) {
lane = laneEnds.length;
laneEnds.add(end);
} else {
laneEnds[lane] = end;
}
cluster.add((entry, start, end, lane));
clusterEnd = max(clusterEnd, end);
}
close();
return (allDay: allDay, blocks: blocks);
}

Some files were not shown because too many files have changed in this diff Show more