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:
parent
63d50f77d4
commit
85a022af98
19 changed files with 1032 additions and 83 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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)),
|
||||
];
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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('; ');
|
||||
|
|
|
|||
Loading…
Reference in a new issue