feat(todo): a project linked to a calendar shows its events beside the tasks (ADR 0033, D39)

A project's editor takes a calendar's address (https:// or webcal://,
refused otherwise), and its events show read-only, marked in the
project's colour: in Today's list and Upcoming's agenda above each day's
tasks, as blocks in the hour grids (Today's Calendar, Upcoming's 3-day)
sharing the lanes with timed tasks and all-day ones under All day, in
the project's Calendar (its agenda and month, its own events alone), and
in saved filters of days. A tap shows what an event is: when, where,
and whose calendar. The grid's lane layout is generic for it
(`layOutItems`); a task's row's box is shared (`rowBox`).

Whats-New: Link a calendar to a project in its settings: its events show beside your tasks in Today, Upcoming and the project.
Whats-New-De: Verknüpfe einen Kalender in den Einstellungen eines Projekts: Seine Termine erscheinen neben deinen Aufgaben in Heute, Demnächst und im Projekt.

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-10 12:37:18 +01:00
commit 85a022af98
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 1032 additions and 83 deletions

File diff suppressed because one or more lines are too long

View file

@ -404,5 +404,10 @@
"locationArrived": "Du bist bei {name}",
"locationLeft": "Du hast {name} verlassen",
"tasksDeleteCompleted": "Erledigte Aufgaben löschen",
"tasksCompletedDeleted": "{count, plural, =1{1 erledigte Aufgabe gelöscht} other{{count} erledigte Aufgaben gelöscht}}"
"tasksCompletedDeleted": "{count, plural, =1{1 erledigte Aufgabe gelöscht} other{{count} erledigte Aufgaben gelöscht}}",
"projectCalendarTitle": "Verknüpfter Kalender",
"projectCalendarAddress": "Kalenderadresse (iCal)",
"projectCalendarHelp": "Seine Termine stehen neben den Aufgaben dieses Projekts, nur zum Lesen. Jedes deiner Geräte ruft ihn selbst ab.",
"projectCalendarInvalid": "Eine Adresse, die mit https:// oder webcal:// beginnt",
"eventFromCalendar": "Aus dem Kalender von {project}"
}

View file

@ -1262,5 +1262,30 @@
"type": "int"
}
}
},
"projectCalendarTitle": "Linked calendar",
"@projectCalendarTitle": {
"description": "The project's editor: the calendar linked to it, whose events show beside its tasks (ADR 0033, D39)."
},
"projectCalendarAddress": "Calendar address (iCal)",
"@projectCalendarAddress": {
"description": "The project's editor: the field for the calendar's feed address (ADR 0033, D39)."
},
"projectCalendarHelp": "Its events show beside this project's tasks, read-only. Each of your devices fetches it itself.",
"@projectCalendarHelp": {
"description": "The project's editor, below the calendar's address (ADR 0033, D39)."
},
"projectCalendarInvalid": "An address starting with https:// or webcal://",
"@projectCalendarInvalid": {
"description": "The project's editor: an address no feed can be fetched from (ADR 0033, D39)."
},
"eventFromCalendar": "From {project}'s calendar",
"@eventFromCalendar": {
"description": "An event's details: which project's calendar it comes from (ADR 0033, D39).",
"placeholders": {
"project": {
"type": "String"
}
}
}
}

View file

@ -2529,6 +2529,36 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =1{1 completed task deleted} other{{count} completed tasks deleted}}'**
String tasksCompletedDeleted(int count);
/// The project's editor: the calendar linked to it, whose events show beside its tasks (ADR 0033, D39).
///
/// In en, this message translates to:
/// **'Linked calendar'**
String get projectCalendarTitle;
/// The project's editor: the field for the calendar's feed address (ADR 0033, D39).
///
/// In en, this message translates to:
/// **'Calendar address (iCal)'**
String get projectCalendarAddress;
/// The project's editor, below the calendar's address (ADR 0033, D39).
///
/// In en, this message translates to:
/// **'Its events show beside this project\'s tasks, read-only. Each of your devices fetches it itself.'**
String get projectCalendarHelp;
/// The project's editor: an address no feed can be fetched from (ADR 0033, D39).
///
/// In en, this message translates to:
/// **'An address starting with https:// or webcal://'**
String get projectCalendarInvalid;
/// An event's details: which project's calendar it comes from (ADR 0033, D39).
///
/// In en, this message translates to:
/// **'From {project}\'s calendar'**
String eventFromCalendar(String project);
}
class _AppLocalizationsDelegate

View file

@ -1589,4 +1589,23 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get projectCalendarTitle => 'Verknüpfter Kalender';
@override
String get projectCalendarAddress => 'Kalenderadresse (iCal)';
@override
String get projectCalendarHelp =>
'Seine Termine stehen neben den Aufgaben dieses Projekts, nur zum Lesen. Jedes deiner Geräte ruft ihn selbst ab.';
@override
String get projectCalendarInvalid =>
'Eine Adresse, die mit https:// oder webcal:// beginnt';
@override
String eventFromCalendar(String project) {
return 'Aus dem Kalender von $project';
}
}

View file

@ -1586,6 +1586,25 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get projectCalendarTitle => 'Linked calendar';
@override
String get projectCalendarAddress => 'Calendar address (iCal)';
@override
String get projectCalendarHelp =>
'Its events show beside this project\'s tasks, read-only. Each of your devices fetches it itself.';
@override
String get projectCalendarInvalid =>
'An address starting with https:// or webcal://';
@override
String eventFromCalendar(String project) {
return 'From $project\'s calendar';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -0,0 +1,173 @@
import 'dart:async';
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/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/model/ical.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_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The events of the calendars linked to the person's projects that run on
/// [day] (ADR 0033, D39), those of [projectId]'s alone where given; none
/// outside a person's app.
List<FeedEvent> eventsOn(
BuildContext context,
Day day, {
String? projectId,
}) => eventsFrom(context, day, 1, projectId: projectId).first;
/// The events of the [days] days from [first] on, a list a day, as
/// [eventsOn].
List<List<FeedEvent>> eventsFrom(
BuildContext context,
Day first,
int days, {
String? projectId,
}) {
final state = context.watch<CalendarCubit?>()?.state;
if (state == null || state.texts.isEmpty) {
return [for (var i = 0; i < days; i++) const []];
}
final all = state.eventsIn(first.toDate(), first.plus(days).toDate());
return [
for (var i = 0; i < days; i++)
[
for (final entry in all)
if ((projectId == null || entry.projectId == projectId) &&
_runsOn(entry.event, first.plus(i)))
entry,
],
];
}
bool _runsOn(IcalEvent event, Day day) =>
event.start.isBefore(day.plus(1).toDate()) &&
event.end.isAfter(day.toDate()) ||
// An event of no length, at midnight.
event.start == day.toDate();
/// When [event] runs on [day], as a row says it: all day, or its times on
/// that day.
String eventTimes(BuildContext context, IcalEvent event, Day day) {
final l10n = AppLocalizations.of(context);
if (event.allDay) {
return l10n.allDay;
}
int minutes(DateTime time) => time.hour * 60 + time.minute;
final from = Day.of(event.start) == day ? minutes(event.start) : 0;
final until = Day.of(event.end) == day ? minutes(event.end) : 24 * 60;
return until == from
? timeText(context, from)
: '${timeText(context, from)}–${timeText(context, until)}';
}
/// An event of a project's calendar on [day] (D39), read-only: a stripe in
/// the project's colour, its title, its times and its place; a tap shows
/// what it is ([showEvent]). Boxed as a task's row is, at [position] in
/// its group.
class const EventTile({
required final FeedEvent entry,
required final Day day,
final TaskRowPosition position = TaskRowPosition.alone,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = context.select<TodoCubit, PaletteColor?>(
(cubit) => cubit.state.colorOf(entry.projectId),
);
final event = entry.event;
final muted = theme.colorScheme.onSurfaceVariant;
return rowBox(
context,
position,
ListTile(
leading: Icon(
LucideIcons.calendarDays,
color: paletteText(context, color ?? PaletteColor.cobalt),
),
title: Text(event.title),
subtitle: Text(
[
eventTimes(context, event, day),
if (event.location case final place? when place.isNotEmpty) place,
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: muted),
),
onTap: () => unawaited(showEvent(context, entry, day)),
),
);
}
}
/// Shows what [entry] is: its title, when it runs on [day], its place, and
/// which project's calendar it comes from; nothing in it can be changed.
Future<void> showEvent(BuildContext context, FeedEvent entry, Day day) {
final l10n = AppLocalizations.of(context);
final state = context.read<TodoCubit>().state;
final project = state.projectOf(entry.projectId)?.content?.name ?? '';
final event = entry.event;
final today = Day.of(clock.now());
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
showDragHandle: true,
builder: (context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(event.title, style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
_Line(
LucideIcons.clock,
'${dayHeading(context, day, today)}, '
'${eventTimes(context, event, day)}',
),
if (event.location case final place? when place.isNotEmpty)
_Line(LucideIcons.mapPin, place),
_Line(
LucideIcons.calendarDays,
l10n.eventFromCalendar(project),
),
],
),
),
);
},
);
}
class const _Line(final IconData icon, final String text)
extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(child: Text(text)),
],
),
);
}

View file

@ -0,0 +1,46 @@
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// Whether [query] takes [entry], an event of a calendar linked to a
/// project, on [day] (ADR 0033, D39): read as a task of its project on that
/// day, without a priority or labels, it answers, and only because of its
/// day: the same task without a date, or a century later, doesn't. A
/// filter of a project alone, or of tasks without a date, takes no events:
/// a calendar's are endless.
bool filterTakesEvent(
FilterQuery query,
FeedEvent entry,
Day day,
FilterWorld world,
) {
OpenTask on(Day? day) => (
id: 'event:${entry.event.uid}',
content: TaskContent(
title: entry.event.title,
projectId: entry.projectId,
date: switch (day) {
final day? => TaskDate(day),
null => null,
},
),
);
return query.matches(on(day), world) &&
!query.matches(on(null), world) &&
!query.matches(on(day.plus(36500)), world);
}
/// The events of [days], a list a day from [first] on, that [query] takes,
/// each with its day.
List<(FeedEvent, Day)> eventsOfFilter(
FilterQuery query,
List<List<FeedEvent>> days,
Day first,
FilterWorld world,
) => [
for (final (index, events) in days.indexed)
for (final event in events)
if (filterTakesEvent(query, event, first.plus(index), world))
(event, first.plus(index)),
];

View file

@ -6,6 +6,9 @@ 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/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/widget/event_tile.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_events.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_editor.dart';
@ -19,6 +22,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_texts.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/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -46,6 +50,10 @@ class const _TaskList({
required final List<OpenTask> tasks,
required final String nothing,
final RichContent? description,
/// The calendars' events a filter takes, each on its day (D39), above
/// the tasks.
final List<(FeedEvent, Day)> events = const [],
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -69,7 +77,13 @@ class const _TaskList({
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: RichContentView(content: description),
),
if (tasks.isEmpty)
...taskGroup([
for (final (event, day) in events)
(position) =>
EventTile(entry: event, day: day, position: position),
]),
if (events.isNotEmpty) const SizedBox(height: 8),
if (tasks.isEmpty && events.isEmpty)
Padding(
padding: const EdgeInsets.all(32),
child: Text(
@ -104,10 +118,21 @@ class const FilterScreen({required final String filterId, super.key})
}
final todo = PersonScope.of<TodoPerson>(context);
final world = filterWorldOf(state);
final tasks = switch (FilterQuery.parse(content.query)) {
final parsed = FilterQuery.parse(content.query);
final tasks = switch (parsed) {
FilterRead(:final query) => tasksOfFilter(query, state.tasks, world),
FilterUnread() => const <OpenTask>[],
};
// The calendars' events of the next four weeks its days take (D39).
final events = switch (parsed) {
FilterRead(:final query) => eventsOfFilter(
query,
eventsFrom(context, world.today, upcomingDays),
world.today,
world,
),
FilterUnread() => const <(FeedEvent, Day)>[],
};
return TabScreen(
title: content.name,
actions: [
@ -163,6 +188,7 @@ class const FilterScreen({required final String filterId, super.key})
),
body: _TaskList(
tasks: tasks,
events: events,
nothing: l10n.filterNothing,
description: content.description,
),

View file

@ -3,6 +3,8 @@ import 'dart:async';
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/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/widget/event_tile.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
@ -45,6 +47,9 @@ class const MonthGrid({
static Key chipKey(String taskId, Day day) =>
ValueKey('month-task-$taskId-$day');
/// The key of the line of the calendar's event [uid] on [day] (D39).
static Key eventKey(String uid, Day day) => ValueKey('month-event-$uid-$day');
/// The key of the count of [day]'s tasks that don't fit.
static Key moreKey(Day day) => ValueKey('month-more-$day');
@ -67,6 +72,13 @@ class _MonthGridState() extends State<MonthGrid> {
void go(Day day) => setState(() => _month = Day(day.year, day.month, 1));
final weeks = monthWeeks(month, firstWeekdayOf(context));
final entries = widget.entriesFor(weeks.first, weeks.last.plus(7));
// The project's calendar's events beside its tasks (D39).
final events = eventsFrom(
context,
weeks.first,
weeks.first.daysUntil(weeks.last.plus(7)),
projectId: widget.projectId,
);
return Column(
children: [
Padding(
@ -149,6 +161,10 @@ class _MonthGridState() extends State<MonthGrid> {
today: today,
inMonth: week.plus(index).month == month.month,
entries: entries[week.plus(index)] ?? const [],
events:
events[weeks.first.daysUntil(
week.plus(index),
)],
projectId: widget.projectId,
),
),
@ -172,6 +188,7 @@ class const _DayCell({
required final bool inMonth,
required final List<CalendarEntry> entries,
required final String projectId,
final List<FeedEvent> events = const [],
super.key,
}) extends StatelessWidget {
@override
@ -200,8 +217,22 @@ class const _DayCell({
((constraints.maxHeight - _dayNumberHeight - 4) / _lineHeight)
.floor()
.clamp(0, 99);
final fits = entries.length <= room;
final shown = fits ? entries : entries.take(room - 1).toList();
// Its events first, then its tasks (D39).
final lines = <Widget>[
for (final event in events)
_EventLine(
key: MonthGrid.eventKey(event.event.uid, day),
entry: event,
day: day,
),
for (final entry in entries)
_TaskLine(
key: MonthGrid.chipKey(entry.task.id, day),
entry: entry,
),
];
final fits = lines.length <= room;
final shown = fits ? lines : lines.take(room - 1).toList();
return Padding(
padding: const EdgeInsets.all(2),
child: Column(
@ -236,14 +267,8 @@ class const _DayCell({
),
),
),
for (final entry in shown)
SizedBox(
height: _lineHeight,
child: _TaskLine(
key: MonthGrid.chipKey(entry.task.id, day),
entry: entry,
),
),
for (final line in shown)
SizedBox(height: _lineHeight, child: line),
if (!fits && room > 0)
SizedBox(
height: _lineHeight,
@ -256,9 +281,10 @@ class const _DayCell({
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: theme.textTheme.labelSmall,
),
onPressed: () => _showDay(context, day, entries),
onPressed: () =>
_showDay(context, day, entries, events),
child: Text(
l10n.monthMore(entries.length - shown.length),
l10n.monthMore(lines.length - shown.length),
),
),
),
@ -336,6 +362,7 @@ Future<void> _showDay(
BuildContext context,
Day day,
List<CalendarEntry> entries,
List<FeedEvent> events,
) => showAdaptiveSheet<void>(
context,
builder: (context) {
@ -348,6 +375,11 @@ Future<void> _showDay(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...taskGroup([
for (final event in events)
(position) =>
EventTile(entry: event, day: day, position: position),
]),
for (final entry in entries)
if (entry.occurrence)
TaskTile(task: entry.task, later: true)
@ -359,3 +391,52 @@ Future<void> _showDay(
);
},
);
/// A calendar's event's line in a day (D39): its project's mark, its time
/// and its title; tapped it shows what it is, and can't be dragged.
class const _EventLine({
required final FeedEvent entry,
required final Day day,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.select<TodoCubit, PaletteColor?>(
(cubit) => cubit.state.colorOf(entry.projectId),
);
final event = entry.event;
return InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => unawaited(showEvent(context, entry, day)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
Icon(
LucideIcons.calendarDays,
size: 11,
color: paletteText(context, palette ?? PaletteColor.cobalt),
),
const SizedBox(width: 4),
Expanded(
child: Text(
[
if (!event.allDay && Day.of(event.start) == day)
timeText(
context,
event.start.hour * 60 + event.start.minute,
),
event.title,
].join(' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall,
),
),
],
),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/data/calendar_feeds.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
@ -69,22 +70,38 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
/// The layout it is made with, which its Display's Reset returns to.
late var _layout = widget.current?.defaultView.layout ?? ProjectLayout.list;
/// The address of the calendar whose events it shows (D39).
late final _calendar = TextEditingController(
text: widget.current?.calendarUrl,
);
/// Whether [_calendar] is none, or an address a feed can be fetched from.
bool get _calendarValid =>
_calendar.text.trim().isEmpty || feedUrl(_calendar.text) != null;
String? get _calendarUrl => switch (_calendar.text.trim()) {
'' => null,
final address => address,
};
@override
void initState() {
super.initState();
// ✓ is on once there is a name.
_name.addListener(() => setState(() {}));
_calendar.addListener(() => setState(() {}));
}
@override
void dispose() {
_name.dispose();
_calendar.dispose();
super.dispose();
}
void _save() {
final name = _name.text.trim();
if (name.isEmpty) {
if (name.isEmpty || !_calendarValid) {
return;
}
final description = switch (_description) {
@ -102,6 +119,7 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
favorite: _favorite,
view: ProjectView(layout: _layout),
defaultView: ProjectView(layout: _layout),
calendarUrl: _calendarUrl,
),
// The rest kept as it is, its Display too (the developer's review,
// 4 Nov 2026); a layout picked anew shows at once.
@ -115,6 +133,7 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
? null
: current.view.copyWith(layout: _layout),
defaultView: current.defaultView.copyWith(layout: _layout),
calendarUrl: () => _calendarUrl,
),
});
}
@ -183,7 +202,7 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
final ui = HalkyonUiLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final canSave = _name.text.trim().isNotEmpty;
final canSave = _name.text.trim().isNotEmpty && _calendarValid;
return Scaffold(
appBar: AppBar(
leading: IconButton(
@ -290,6 +309,29 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
l10n.projectCalendarTitle,
style: theme.textTheme.titleSmall,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: TextField(
controller: _calendar,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
labelText: l10n.projectCalendarAddress,
prefixIcon: const Icon(LucideIcons.calendarDays),
helperText: l10n.projectCalendarHelp,
helperMaxLines: 3,
errorText: _calendarValid ? null : l10n.projectCalendarInvalid,
),
onSubmitted: (_) => _save(),
),
),
],
),
);

View file

@ -2,11 +2,12 @@ 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,
/// A block in a day of the 3-day grid (ADR 0033, D17), a timed task's or a
/// calendar's event's (D39): from `start` to `end` (minutes after
/// midnight), in `lane` of the `lanes` its overlapping blocks share the
/// day's width with.
typedef GridBlock<T> = ({
T entry,
int start,
int end,
int lane,
@ -22,27 +23,47 @@ const minBlockMinutes = 30;
/// (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> allDay, List<GridBlock<CalendarEntry>> blocks}) layOutDay(
List<CalendarEntry> entries,
) => layOutItems(entries, taskSpan);
/// When [entry]'s task starts and ends on its day, minutes after midnight;
/// none for one without a time.
(int, int)? taskSpan(CalendarEntry entry) {
final date = entry.task.content.date;
return switch (date?.time) {
final time? => (time, time + (date!.duration?.inMinutes ?? 0)),
null => null,
};
}
/// [items] laid out as [layOutDay] lays out tasks, [span] saying when each
/// starts and ends on the day (minutes after midnight), none for one of
/// all day.
({List<T> allDay, List<GridBlock<T>> blocks}) layOutItems<T>(
List<T> items,
(int, int)? Function(T) span,
) {
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)));
final allDay = <T>[];
final timed = <(T, int, int)>[];
for (final item in items) {
if (span(item) case (final start, final end)) {
timed.add((
item,
start,
min(max(end, start + minBlockMinutes), 24 * 60),
));
} else {
allDay.add(entry);
allDay.add(item);
}
}
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>[];
final blocks = <GridBlock<T>>[];
// The blocks of the cluster being laid out, and where each lane ends.
var cluster = <(CalendarEntry, int, int, int)>[];
var cluster = <(T, int, int, int)>[];
var laneEnds = <int>[];
var clusterEnd = -1;
void close() {

View file

@ -293,7 +293,7 @@ class const TaskTile({
);
}
if (selection == null) {
return _boxed(context, tile);
return rowBox(context, position, tile);
}
Widget swipe(IconData icon, Color color, Alignment alignment) => ColoredBox(
color: color,
@ -336,32 +336,7 @@ class const TaskTile({
child: tile,
),
);
return _boxed(context, row);
}
/// [row] in its rounded box, apart from the next by a hairline.
Widget _boxed(BuildContext context, Widget row) {
const outer = Radius.circular(16);
const inner = Radius.circular(4);
final top = switch (position) {
TaskRowPosition.alone || TaskRowPosition.first => outer,
_ => inner,
};
final bottom = switch (position) {
TaskRowPosition.alone || TaskRowPosition.last => outer,
_ => inner,
};
return Padding(
padding: const EdgeInsets.fromLTRB(12, 1, 12, 1),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: top, bottom: bottom),
),
child: row,
),
);
return rowBox(context, position, row);
}
/// Asks for the task's date and writes it.
@ -426,6 +401,32 @@ class const TaskCheck({required final OpenTask task, super.key})
}
}
/// [row] in its rounded box at [position] in its group, apart from the next
/// by a hairline: a task's, or a calendar's event's (D39).
Widget rowBox(BuildContext context, TaskRowPosition position, Widget row) {
const outer = Radius.circular(16);
const inner = Radius.circular(4);
final top = switch (position) {
TaskRowPosition.alone || TaskRowPosition.first => outer,
_ => inner,
};
final bottom = switch (position) {
TaskRowPosition.alone || TaskRowPosition.last => outer,
_ => inner,
};
return Padding(
padding: const EdgeInsets.fromLTRB(12, 1, 12, 1),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: top, bottom: bottom),
),
child: row,
),
);
}
/// Where a row stands in its list's group (the developer's review, 3 Nov
/// 2026): the group's box is rounded at its ends only.
enum TaskRowPosition() {

View file

@ -3,6 +3,8 @@ import 'dart:math';
import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/widget/event_tile.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
@ -120,6 +122,27 @@ class _DayAgendaState() extends State<DayAgenda> {
today.daysUntil(WeekStrip.weekOf(selected, firstWeekday).plus(6)) + 1,
);
final (:overdue, :days, :busy) = widget.calendarFor(reach);
// The calendars' events beside the tasks (D39): a project's own alone
// in its Calendar.
final events = days.isEmpty
? const <List<FeedEvent>>[]
: eventsFrom(
context,
days.first.$1,
days.first.$1.daysUntil(days.last.$1) + 1,
projectId: widget.projectId,
);
List<FeedEvent> eventsOf(Day day) => switch (days.first.$1.daysUntil(
day,
)) {
final at when at >= 0 && at < events.length => events[at],
_ => const [],
};
final busyDays = {
...busy,
for (final (day, _) in days)
if (eventsOf(day).isNotEmpty) day,
};
Widget row(CalendarEntry entry, TaskRowPosition position) =>
entry.occurrence
? TaskTile(
@ -141,7 +164,7 @@ class _DayAgendaState() extends State<DayAgenda> {
WeekStrip(
selected: selected,
today: today,
busy: busy,
busy: busyDays,
trailing: widget.trailing,
onSelect: (day) => unawaited(_select(day)),
),
@ -189,7 +212,7 @@ class _DayAgendaState() extends State<DayAgenda> {
children: [
DayHeading(
title: dayHeading(context, day, today),
quiet: entries.isEmpty,
quiet: entries.isEmpty && eventsOf(day).isEmpty,
action: IconButton(
tooltip: l10n.taskAddOn(
dayHeading(context, day, today),
@ -202,6 +225,16 @@ class _DayAgendaState() extends State<DayAgenda> {
),
),
),
...taskGroup([
for (final event in eventsOf(day))
(position) => EventTile(
entry: event,
day: day,
position: position,
),
]),
if (eventsOf(day).isNotEmpty && entries.isNotEmpty)
const SizedBox(height: 8),
...taskGroup([
for (final entry in entries)
(position) => row(entry, position),

View file

@ -5,6 +5,9 @@ import 'package:flutter/gestures.dart'
show DragStartBehavior, PointerDeviceKind;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/model/ical.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/widget/event_tile.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.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';
@ -85,6 +88,9 @@ class const HourGrid({
/// The key of the line at the time it is now.
static const nowKey = ValueKey('grid-now');
/// The key of the block of the calendar's event [uid] on [day] (D39).
static Key eventKey(String uid, Day day) => ValueKey('grid-event-$uid-$day');
/// The strip of a day's width its blocks leave free on the right, where
/// a tap, a click or a stretch held and dragged still adds a task beside
/// them, as Proton Calendar's (the developer's review, 10 Nov 2026).
@ -159,9 +165,25 @@ class _HourGridState() extends State<HourGrid> {
final (:overdue, :days, busy: _) = widget.calendarFor(
today.daysUntil(start) + count,
);
// The calendars' events beside the tasks (D39).
final events = eventsFrom(context, start, count);
final shown = [
for (final (day, entries) in days)
if (!(day < start)) (day, layOutDay(entries)),
if (!(day < start))
(
day,
layOutItems<_Item>(
[
if (start.daysUntil(day) case final at when at < count)
for (final event in events[at]) _EventItem(event),
for (final entry in entries) _TaskItem(entry),
],
(item) => switch (item) {
_TaskItem(:final entry) => taskSpan(entry),
_EventItem(:final entry) => eventSpan(entry.event, day),
},
),
),
];
final nowColor = paletteText(context, PaletteColor.cerise);
final nowShown = shown.any((shown) => shown.$1 == today);
@ -244,19 +266,29 @@ class _HourGridState() extends State<HourGrid> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final entry in allDay)
for (final item in allDay)
Padding(
padding: const EdgeInsets.all(2),
child: SizedBox(
height: 26,
child: _TaskBlock(
key: HourGrid.blockKey(
entry.task.id,
day,
child: switch (item) {
_TaskItem(:final entry) => _TaskBlock(
key: HourGrid.blockKey(
entry.task.id,
day,
),
entry: entry,
coloring: widget.coloring,
),
entry: entry,
coloring: widget.coloring,
),
_EventItem(:final entry) => _EventBlock(
key: HourGrid.eventKey(
entry.event.uid,
day,
),
entry: entry,
day: day,
),
},
),
),
],
@ -443,7 +475,7 @@ class const _DayName({
/// hour adding a task there.
class const _DayColumn({
required final Day day,
required final List<GridBlock> blocks,
required final List<GridBlock<_Item>> blocks,
required final TaskColoring coloring,
required final Color nowColor,
final int? now,
@ -664,14 +696,18 @@ class _DayColumnState() extends State<_DayColumn> {
height: top(block.end) - top(block.start) - 2,
left: block.lane * blocksWidth / block.lanes + 1,
width: blocksWidth / block.lanes - 2,
child: _TaskBlock(
key: HourGrid.blockKey(
block.entry.task.id,
widget.day,
child: switch (block.entry) {
_TaskItem(:final entry) => _TaskBlock(
key: HourGrid.blockKey(entry.task.id, widget.day),
entry: entry,
coloring: widget.coloring,
),
entry: block.entry,
coloring: widget.coloring,
),
_EventItem(:final entry) => _EventBlock(
key: HourGrid.eventKey(entry.event.uid, widget.day),
entry: entry,
day: widget.day,
),
},
),
if (widget.now case final now?)
Positioned(
@ -900,3 +936,67 @@ class const _TaskBlock({
: _DraggableBlock(taskId: entry.task.id, child: block);
}
}
/// What a day of the grid holds: a task, or an event of a calendar linked
/// to a project (D39).
sealed class const _Item();
final class const _TaskItem(final CalendarEntry entry) extends _Item;
final class const _EventItem(final FeedEvent entry) extends _Item;
/// When [event] runs on [day], minutes after midnight; none where it is
/// all day, or runs through the whole day.
(int, int)? eventSpan(IcalEvent event, Day day) {
if (event.allDay) {
return null;
}
int minutes(DateTime time) => time.hour * 60 + time.minute;
final from = Day.of(event.start) == day ? minutes(event.start) : 0;
final until = Day.of(event.end) == day ? minutes(event.end) : 24 * 60;
return from == 0 && until == 24 * 60 ? null : (from, until);
}
/// A calendar's event's block (D39), read-only: tinted in its project's
/// colour, its title; a tap shows what it is.
class const _EventBlock({
required final FeedEvent entry,
required final Day day,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final palette = context.select<TodoCubit, PaletteColor?>(
(cubit) => cubit.state.colorOf(entry.projectId),
);
final color = paletteText(context, palette ?? PaletteColor.cobalt);
return Material(
color: Color.alphaBlend(color.withValues(alpha: 0.28), scheme.surface),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => unawaited(showEvent(context, entry, day)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(LucideIcons.calendarDays, size: 12, color: color),
const SizedBox(width: 4),
Expanded(
child: Text(
entry.event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium,
),
),
],
),
),
),
);
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
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/calendar/widget/event_tile.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/report/widget/reporting_screen.dart';
@ -134,10 +135,12 @@ class _TodayTabState() extends State<TodayTab> with PlanHost<TodayTab> {
List<OpenTask> due,
) {
final l10n = AppLocalizations.of(context);
// Today's events of the calendars linked to projects (D39).
final events = eventsOn(context, today);
// The add button dropped on the list adds a task for today (D16).
return NewTaskDropArea(
onDrop: () => showQuickAdd(context, day: today),
child: overdue.isEmpty && due.isEmpty
child: overdue.isEmpty && due.isEmpty && events.isEmpty
? TaskRefresh(
fill: true,
child: EmptyListMessage(
@ -190,6 +193,16 @@ class _TodayTabState() extends State<TodayTab> with PlanHost<TodayTab> {
]),
],
DayHeading(title: dayTitle(context, today)),
...taskGroup([
for (final event in events)
(position) => EventTile(
entry: event,
day: today,
position: position,
),
]),
if (events.isNotEmpty && due.isNotEmpty)
const SizedBox(height: 8),
...taskGroup([
for (final task in due)
(position) => TaskTile(

View file

@ -4585,6 +4585,247 @@ void main() {
expect(yard?.view, const ProjectView(layout: ProjectLayout.board));
});
group("a project's linked calendar (D39, the developer's decisions of "
'10 Nov 2026)', () {
ProjectRepository projects() => ProjectRepository(projectRows, keys);
final school = Uri.parse('https://example.org/school.ics');
/// A calendar with "Maths" today, nine to ten, and "Sports day" all
/// of today.
String feed() {
final today = Day.of(DateTime.now());
String date(Day day) =>
'${day.year}${'${day.month}'.padLeft(2, '0')}'
'${'${day.day}'.padLeft(2, '0')}';
return 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\n'
'BEGIN:VEVENT\r\nUID:maths@test\r\n'
'DTSTART:${date(today)}T090000\r\nDTEND:${date(today)}T100000\r\n'
'SUMMARY:Maths\r\nLOCATION:Room 4\r\nEND:VEVENT\r\n'
'BEGIN:VEVENT\r\nUID:sports@test\r\n'
'DTSTART;VALUE=DATE:${date(today)}\r\n'
'SUMMARY:Sports day\r\nEND:VEVENT\r\n'
'END:VCALENDAR\r\n';
}
/// School, linked to its calendar, and Home, linked to none.
Future<void> seedSchool(WidgetTester tester) async {
calendarFeeds.served[school] = feed();
await tester.runAsync(() async {
await projects().save(
ProjectContent(
name: 'School',
position: 1,
calendarUrl: '$school',
),
);
await projects().save(
const ProjectContent(name: 'Home', position: 2),
);
});
projectRows.bringRemoteChanges();
await tester.pumpAndSettle();
}
testWidgets("linked in the project's editor, an address no calendar is "
'at refused, and fetched by the device once saved', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => projects().save(const ProjectContent(name: 'School')),
);
projectRows.bringRemoteChanges();
calendarFeeds.served[school] = feed();
await goTab(tester, 'Browse');
await tester.tap(find.text('School'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Edit project'));
await tester.pumpAndSettle();
final address = find.widgetWithText(
TextField,
'Calendar address (iCal)',
);
await tester.ensureVisible(address);
await tester.enterText(address, 'school timetable');
await tester.pump();
expect(
find.text('An address starting with https:// or webcal://'),
findsOneWidget,
);
expect(
tester
.widget<IconButton>(
find.widgetWithIcon(IconButton, LucideIcons.check),
)
.onPressed,
isNull,
);
await tester.enterText(address, 'webcal://example.org/school.ics');
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(projects().all);
expect(
stored!.singleWhere((p) => !p.inbox).content?.calendarUrl,
'webcal://example.org/school.ics',
);
expect(calendarFeeds.fetched, [school]);
});
testWidgets('its events show in Upcoming and Today, read-only, a tap '
'opening what they are', (tester) async {
await start(tester, usedBefore: true);
await seedSchool(tester);
await goTab(tester, 'Upcoming');
expect(find.text('Maths'), findsOneWidget);
expect(find.text('Sports day'), findsOneWidget);
await tester.tap(find.text('Maths'));
await tester.pumpAndSettle();
expect(find.text("From School's calendar"), findsOneWidget);
expect(find.text('Room 4'), findsWidgets);
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
await goTab(tester, 'Today');
expect(find.text('Maths'), findsOneWidget);
expect(find.text('Sports day'), findsOneWidget);
expect(find.byType(DraggableTask), findsNothing);
});
testWidgets("in Upcoming's 3-day grid, a timed one a block at its "
'hours, an all-day one under All day; a tap opens what it is', (
tester,
) async {
await start(tester, usedBefore: true);
await seedSchool(tester);
await goTab(tester, 'Upcoming');
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
await tester.tap(find.text('3-day'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
await tester.pumpAndSettle();
final today = Day.of(DateTime.now());
final maths = find.byKey(HourGrid.eventKey('maths@test', today));
final column = find.byKey(HourGrid.columnKey(today));
// An hour is 60 logical pixels down the day.
expect(
tester.getTopLeft(maths).dy - tester.getTopLeft(column).dy,
closeTo(9 * 60, 2),
);
expect(tester.getSize(maths).height, closeTo(58, 2));
expect(
find.descendant(
of: find.byKey(HourGrid.allDayKey(today)),
matching: find.text('Sports day'),
),
findsOneWidget,
);
await tester.ensureVisible(maths);
await tester.pumpAndSettle();
await tester.tap(maths);
await tester.pumpAndSettle();
expect(find.text("From School's calendar"), findsOneWidget);
});
testWidgets("in its project's month, on a wide window, before the "
"day's tasks", (tester) async {
await start(tester, usedBefore: true);
await seedSchool(tester);
tester.view.physicalSize = const Size(1000, 900);
final all = await tester.runAsync(projects().all);
final id = all!.singleWhere((p) => p.content?.name == 'School').id;
await tester.runAsync(
() => projects().update(
id,
all
.singleWhere((p) => p.id == id)
.content!
.copyWith(
view: const ProjectView(layout: ProjectLayout.calendar),
),
),
);
projectRows.bringRemoteChanges();
await goTab(tester, 'Browse');
await tester.tap(find.text('School'));
await tester.pumpAndSettle();
final today = Day.of(DateTime.now());
expect(
find.byKey(MonthGrid.eventKey('maths@test', today)),
findsOneWidget,
);
expect(find.text('09:00 Maths'), findsOneWidget);
});
testWidgets("in a saved filter of days, today's; not in one of its "
'project alone', (tester) async {
await start(tester, usedBefore: true);
await seedSchool(tester);
await tester.runAsync(() async {
final filters = FilterRepository(filterRows, keys);
await filters.add(
const FilterContent(name: 'Day', query: 'today', favorite: true),
);
await filters.add(
const FilterContent(
name: 'All of school',
query: '#School',
favorite: true,
),
);
});
filterRows.bringRemoteChanges();
await tester.pumpAndSettle();
await goTab(tester, 'Browse');
await tester.tap(find.text('Day'));
await tester.pumpAndSettle();
expect(find.text('Maths'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('All of school'));
await tester.pumpAndSettle();
expect(find.text('Maths'), findsNothing);
});
testWidgets("in its project's Calendar, but not in another's", (
tester,
) async {
await start(tester, usedBefore: true);
await seedSchool(tester);
final all = await tester.runAsync(projects().all);
for (final project in all!.where((p) => !p.inbox)) {
await tester.runAsync(
() => projects().update(
project.id,
project.content!.copyWith(
view: const ProjectView(layout: ProjectLayout.calendar),
),
),
);
}
projectRows.bringRemoteChanges();
await goTab(tester, 'Browse');
await tester.tap(find.text('School'));
await tester.pumpAndSettle();
expect(find.text('Maths'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
expect(find.text('Maths'), findsNothing);
});
});
group("a project's editor as Todoist's Add project (D28)", () {
ProjectRepository projects() => ProjectRepository(projectRows, keys);

View file

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/bloc/calendar_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/calendar/model/ical.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_events.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// Which events of a calendar linked to a project a saved filter shows
/// (ADR 0033, D39): those its days take, by their project and day.
void main() {
const today = Day(2026, 11, 10);
const world = FilterWorld(
today: today,
projects: {
'school': (name: 'School', parentId: null),
'home': (name: 'Home', parentId: null),
},
);
FeedEvent maths(Day day, {String projectId = 'school'}) => FeedEvent(
projectId,
IcalEvent(
uid: 'maths',
title: 'Maths',
start: DateTime(day.year, day.month, day.day, 9),
end: DateTime(day.year, day.month, day.day, 10),
),
);
bool takes(String query, FeedEvent event, Day day) =>
switch (FilterQuery.parse(query)) {
FilterRead(:final query) => filterTakesEvent(query, event, day, world),
FilterUnread() => fail('$query not read'),
};
test('a filter of days takes the events on them, of its projects', () {
expect(takes('today', maths(today), today), isTrue);
expect(takes('today', maths(today.plus(1)), today.plus(1)), isFalse);
expect(
takes('7 days & #School', maths(today.plus(2)), today.plus(2)),
isTrue,
);
expect(
takes('7 days & #School', maths(today, projectId: 'home'), today),
isFalse,
);
});
test('a filter not of days takes none: its project, a label, no date', () {
expect(takes('#School', maths(today), today), isFalse);
expect(takes('no date', maths(today), today), isFalse);
expect(takes('p1 & today', maths(today), today), isFalse);
});
test(
'the events of the next four weeks a filter takes, each on its days',
() {
final events = eventsOfFilter(
switch (FilterQuery.parse('7 days')) {
FilterRead(:final query) => query,
FilterUnread() => fail('not read'),
},
[
for (var day = 0; day < 28; day++) [maths(today.plus(day))],
],
today,
world,
);
expect(events.map((event) => event.$2), [
for (var day = 0; day < 7; day++) today.plus(day),
]);
},
);
}

View file

@ -20,7 +20,7 @@ void main() {
occurrence: false,
);
String laid(List<GridBlock> blocks) => [
String laid(List<GridBlock<CalendarEntry>> blocks) => [
for (final b in blocks)
'${b.entry.task.id} ${b.start}-${b.end} ${b.lane}/${b.lanes}',
].join('; ');