diff --git a/README.md b/README.md
index 2b370f3..d1ebd6d 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,75 @@
# BusyMax
BusyMax is a Linux desktop calendar and task manager built with Flutter.
-It supports `Google Calendar`, `Google Tasks`, `Microsoft Calendar`, and `Microsoft To Do`.
+
+It brings calendar events and tasks into a native-feeling Linux desktop interface, with support for `Google Calendar`, `Google Tasks`, `Microsoft Calendar`, and `Microsoft To Do`.
+
+
+
+
+
+
+ Month view with calendars, tasks, and event details.
+
+
+## Highlights
+
+- Linux desktop app built with Flutter.
+- Calendar views for day, week, month, year, and agenda planning.
+- Task creation with lists, due dates, reminders, and repeat options.
+- Event editing with calendar selection, time controls, repeat rules, and reminders.
+- Compact agenda window for quick access to upcoming work.
+- Integrations with Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do.
+
+## Screenshots
+
+
+
+
+
+
+ Week view with color-coded calendars and scheduled tasks.
+
+
+
+
+ Day view for focused daily planning.
+
+
+
+
+
+
+ Agenda view with upcoming events, tasks, and details.
+
+
+
+
+ Task creation with lists, due dates, reminders, and repeat options.
+
+
+
+
+
+More screenshots
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## Prerequisites
@@ -16,4 +84,4 @@ flutter run -d linux \
--dart-define=GOOGLE_OAUTH_CLIENT_ID= \
--dart-define=GOOGLE_OAUTH_CLIENT_SECRET= \
--dart-define=MICROSOFT_OAUTH_CLIENT_ID=
-```
+```
\ No newline at end of file
diff --git a/assets/branding/busymax-logo.png b/assets/branding/busymax-logo.png
deleted file mode 100644
index 6749c88..0000000
Binary files a/assets/branding/busymax-logo.png and /dev/null differ
diff --git a/assets/branding/busymax-logo.svg b/assets/branding/busymax-logo.svg
new file mode 100644
index 0000000..f34fa48
--- /dev/null
+++ b/assets/branding/busymax-logo.svg
@@ -0,0 +1,67 @@
+
+ BusyMax logo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/screenshots/agenda_window_agenda.png b/docs/screenshots/agenda_window_agenda.png
new file mode 100644
index 0000000..6ec1ad3
Binary files /dev/null and b/docs/screenshots/agenda_window_agenda.png differ
diff --git a/docs/screenshots/agenda_window_agenda_event_details.png b/docs/screenshots/agenda_window_agenda_event_details.png
new file mode 100644
index 0000000..bb5afac
Binary files /dev/null and b/docs/screenshots/agenda_window_agenda_event_details.png differ
diff --git a/docs/screenshots/agenda_window_edit_event.png b/docs/screenshots/agenda_window_edit_event.png
new file mode 100644
index 0000000..1fd96cb
Binary files /dev/null and b/docs/screenshots/agenda_window_edit_event.png differ
diff --git a/docs/screenshots/main_window_agenda.png b/docs/screenshots/main_window_agenda.png
new file mode 100644
index 0000000..dd79a89
Binary files /dev/null and b/docs/screenshots/main_window_agenda.png differ
diff --git a/docs/screenshots/main_window_day.png b/docs/screenshots/main_window_day.png
new file mode 100644
index 0000000..b1b5bf2
Binary files /dev/null and b/docs/screenshots/main_window_day.png differ
diff --git a/docs/screenshots/main_window_edit_event.png b/docs/screenshots/main_window_edit_event.png
new file mode 100644
index 0000000..d285d80
Binary files /dev/null and b/docs/screenshots/main_window_edit_event.png differ
diff --git a/docs/screenshots/main_window_month.png b/docs/screenshots/main_window_month.png
new file mode 100644
index 0000000..f717620
Binary files /dev/null and b/docs/screenshots/main_window_month.png differ
diff --git a/docs/screenshots/main_window_new_task.png b/docs/screenshots/main_window_new_task.png
new file mode 100644
index 0000000..94b6847
Binary files /dev/null and b/docs/screenshots/main_window_new_task.png differ
diff --git a/docs/screenshots/main_window_week.png b/docs/screenshots/main_window_week.png
new file mode 100644
index 0000000..1509637
Binary files /dev/null and b/docs/screenshots/main_window_week.png differ
diff --git a/docs/screenshots/main_window_year.png b/docs/screenshots/main_window_year.png
new file mode 100644
index 0000000..b49918b
Binary files /dev/null and b/docs/screenshots/main_window_year.png differ
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index e954aed..7fabbe3 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -48,11 +48,32 @@
"trayAgendaOpenBusyMax": "App öffnen",
"trayAgendaRefresh": "Aktualisieren",
"trayAgendaError": "Agenda nicht verfügbar",
+ "compactAgendaTitle": "Agenda",
+ "compactAgendaSubtitle": "Anstehend",
+ "compactAgendaOverdue": "Überfällig",
+ "compactAgendaClear": "Im Moment frei",
+ "compactAgendaOpenBusyMax": "BusyMax öffnen",
+ "compactAgendaHide": "Ausblenden",
+ "compactAgendaNewTask": "Neue Aufgabe",
+ "compactAgendaRetry": "Erneut versuchen",
+ "compactAgendaRefresh": "Aktualisieren",
+ "compactAgendaAllDay": "Ganztägig",
+ "compactAgendaDueToday": "Heute fällig",
+ "compactAgendaDueTomorrow": "Morgen fällig",
+ "compactAgendaDueOn": "Fällig {date}",
+ "compactAgendaMoreOverdue": "Weitere überfällige Aufgaben laden",
+ "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden",
+ "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden",
"viewDay": "Tag",
"viewWeek": "Woche",
"viewMonth": "Monat",
"viewYear": "Jahr",
"viewAgenda": "Agenda",
+ "scheduleSettings": "Zeitplan",
+ "scheduleDisplaySettings": "Zeitplananzeige",
+ "scheduleDisplayHoursDescription": "Tages- und Wochenansicht öffnen innerhalb dieser Zeiten. Frühe und späte Einträge erweitern den Bereich bei Bedarf.",
+ "scheduleDayStartsAt": "Tag beginnt um",
+ "scheduleDayEndsAt": "Tag endet um",
"sourceCalendar": "Kalender",
"sourceTaskList": "Aufgabenliste",
"createChoiceTitle": "Erstellen",
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index d7900bf..bbddff6 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -49,11 +49,33 @@
"trayAgendaOpenBusyMax": "Open app",
"trayAgendaRefresh": "Refresh",
"trayAgendaError": "Agenda unavailable",
+ "compactAgendaTitle": "Agenda",
+ "compactAgendaSubtitle": "Upcoming",
+ "compactAgendaOverdue": "Overdue",
+ "compactAgendaClear": "Clear for now",
+ "compactAgendaOpenBusyMax": "Open BusyMax",
+ "compactAgendaHide": "Hide",
+ "compactAgendaNewTask": "New task",
+ "compactAgendaRetry": "Retry",
+ "compactAgendaRefresh": "Refresh",
+ "compactAgendaAllDay": "All day",
+ "compactAgendaDueToday": "Due today",
+ "compactAgendaDueTomorrow": "Due tomorrow",
+ "compactAgendaDueOn": "Due {date}",
+ "@compactAgendaDueOn": {"placeholders": {"date": {"type": "String"}}},
+ "compactAgendaMoreOverdue": "Load more overdue tasks",
+ "agendaLoadMoreOverdue": "Load more overdue tasks",
+ "agendaLoadMoreNoDate": "Load more no-date tasks",
"viewDay": "Day",
"viewWeek": "Week",
"viewMonth": "Month",
"viewYear": "Year",
"viewAgenda": "Agenda",
+ "scheduleSettings": "Schedule",
+ "scheduleDisplaySettings": "Schedule display",
+ "scheduleDisplayHoursDescription": "Day and Week views open within these hours. Early and late items expand the range when needed.",
+ "scheduleDayStartsAt": "Day starts at",
+ "scheduleDayEndsAt": "Day ends at",
"sourceCalendar": "Calendar",
"sourceTaskList": "Task list",
"createChoiceTitle": "Create",
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index b568db2..8afe71e 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -48,11 +48,32 @@
"trayAgendaOpenBusyMax": "Abrir app",
"trayAgendaRefresh": "Actualizar",
"trayAgendaError": "Agenda no disponible",
+ "compactAgendaTitle": "Agenda",
+ "compactAgendaSubtitle": "Próximamente",
+ "compactAgendaOverdue": "Vencidas",
+ "compactAgendaClear": "Libre por ahora",
+ "compactAgendaOpenBusyMax": "Abrir BusyMax",
+ "compactAgendaHide": "Ocultar",
+ "compactAgendaNewTask": "Nueva tarea",
+ "compactAgendaRetry": "Reintentar",
+ "compactAgendaRefresh": "Actualizar",
+ "compactAgendaAllDay": "Todo el día",
+ "compactAgendaDueToday": "Vence hoy",
+ "compactAgendaDueTomorrow": "Vence mañana",
+ "compactAgendaDueOn": "Vence {date}",
+ "compactAgendaMoreOverdue": "Cargar más tareas vencidas",
+ "agendaLoadMoreOverdue": "Cargar más tareas vencidas",
+ "agendaLoadMoreNoDate": "Cargar más tareas sin fecha",
"viewDay": "Día",
"viewWeek": "Semana",
"viewMonth": "Mes",
"viewYear": "Año",
"viewAgenda": "Agenda",
+ "scheduleSettings": "Agenda",
+ "scheduleDisplaySettings": "Visualización de agenda",
+ "scheduleDisplayHoursDescription": "Las vistas Día y Semana se abren dentro de este horario. Los elementos tempranos y tardíos amplían el intervalo si hace falta.",
+ "scheduleDayStartsAt": "El día empieza a las",
+ "scheduleDayEndsAt": "El día termina a las",
"sourceCalendar": "Calendario",
"sourceTaskList": "Lista de tareas",
"createChoiceTitle": "Crear",
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index e4d265d..872d430 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -48,11 +48,32 @@
"trayAgendaOpenBusyMax": "Ouvrir l’app",
"trayAgendaRefresh": "Actualiser",
"trayAgendaError": "Agenda indisponible",
+ "compactAgendaTitle": "Agenda",
+ "compactAgendaSubtitle": "À venir",
+ "compactAgendaOverdue": "En retard",
+ "compactAgendaClear": "Libre pour le moment",
+ "compactAgendaOpenBusyMax": "Ouvrir BusyMax",
+ "compactAgendaHide": "Masquer",
+ "compactAgendaNewTask": "Nouvelle tâche",
+ "compactAgendaRetry": "Réessayer",
+ "compactAgendaRefresh": "Actualiser",
+ "compactAgendaAllDay": "Toute la journée",
+ "compactAgendaDueToday": "Échéance aujourd’hui",
+ "compactAgendaDueTomorrow": "Échéance demain",
+ "compactAgendaDueOn": "Échéance {date}",
+ "compactAgendaMoreOverdue": "Charger plus de tâches en retard",
+ "agendaLoadMoreOverdue": "Charger plus de tâches en retard",
+ "agendaLoadMoreNoDate": "Charger plus de tâches sans date",
"viewDay": "Jour",
"viewWeek": "Semaine",
"viewMonth": "Mois",
"viewYear": "Année",
"viewAgenda": "Agenda",
+ "scheduleSettings": "Planning",
+ "scheduleDisplaySettings": "Affichage du planning",
+ "scheduleDisplayHoursDescription": "Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments tôt ou tardifs l’élargissent si nécessaire.",
+ "scheduleDayStartsAt": "La journée commence à",
+ "scheduleDayEndsAt": "La journée se termine à",
"sourceCalendar": "Calendrier",
"sourceTaskList": "Liste de tâches",
"createChoiceTitle": "Créer",
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index 4b80faf..6e53b70 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -390,6 +390,102 @@ abstract class AppLocalizations {
/// **'Agenda unavailable'**
String get trayAgendaError;
+ /// No description provided for @compactAgendaTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Agenda'**
+ String get compactAgendaTitle;
+
+ /// No description provided for @compactAgendaSubtitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Upcoming'**
+ String get compactAgendaSubtitle;
+
+ /// No description provided for @compactAgendaOverdue.
+ ///
+ /// In en, this message translates to:
+ /// **'Overdue'**
+ String get compactAgendaOverdue;
+
+ /// No description provided for @compactAgendaClear.
+ ///
+ /// In en, this message translates to:
+ /// **'Clear for now'**
+ String get compactAgendaClear;
+
+ /// No description provided for @compactAgendaOpenBusyMax.
+ ///
+ /// In en, this message translates to:
+ /// **'Open BusyMax'**
+ String get compactAgendaOpenBusyMax;
+
+ /// No description provided for @compactAgendaHide.
+ ///
+ /// In en, this message translates to:
+ /// **'Hide'**
+ String get compactAgendaHide;
+
+ /// No description provided for @compactAgendaNewTask.
+ ///
+ /// In en, this message translates to:
+ /// **'New task'**
+ String get compactAgendaNewTask;
+
+ /// No description provided for @compactAgendaRetry.
+ ///
+ /// In en, this message translates to:
+ /// **'Retry'**
+ String get compactAgendaRetry;
+
+ /// No description provided for @compactAgendaRefresh.
+ ///
+ /// In en, this message translates to:
+ /// **'Refresh'**
+ String get compactAgendaRefresh;
+
+ /// No description provided for @compactAgendaAllDay.
+ ///
+ /// In en, this message translates to:
+ /// **'All day'**
+ String get compactAgendaAllDay;
+
+ /// No description provided for @compactAgendaDueToday.
+ ///
+ /// In en, this message translates to:
+ /// **'Due today'**
+ String get compactAgendaDueToday;
+
+ /// No description provided for @compactAgendaDueTomorrow.
+ ///
+ /// In en, this message translates to:
+ /// **'Due tomorrow'**
+ String get compactAgendaDueTomorrow;
+
+ /// No description provided for @compactAgendaDueOn.
+ ///
+ /// In en, this message translates to:
+ /// **'Due {date}'**
+ String compactAgendaDueOn(String date);
+
+ /// No description provided for @compactAgendaMoreOverdue.
+ ///
+ /// In en, this message translates to:
+ /// **'Load more overdue tasks'**
+ String get compactAgendaMoreOverdue;
+
+ /// No description provided for @agendaLoadMoreOverdue.
+ ///
+ /// In en, this message translates to:
+ /// **'Load more overdue tasks'**
+ String get agendaLoadMoreOverdue;
+
+ /// No description provided for @agendaLoadMoreNoDate.
+ ///
+ /// In en, this message translates to:
+ /// **'Load more no-date tasks'**
+ String get agendaLoadMoreNoDate;
+
/// No description provided for @viewDay.
///
/// In en, this message translates to:
@@ -420,6 +516,36 @@ abstract class AppLocalizations {
/// **'Agenda'**
String get viewAgenda;
+ /// No description provided for @scheduleSettings.
+ ///
+ /// In en, this message translates to:
+ /// **'Schedule'**
+ String get scheduleSettings;
+
+ /// No description provided for @scheduleDisplaySettings.
+ ///
+ /// In en, this message translates to:
+ /// **'Schedule display'**
+ String get scheduleDisplaySettings;
+
+ /// No description provided for @scheduleDisplayHoursDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'Day and Week views open within these hours. Early and late items expand the range when needed.'**
+ String get scheduleDisplayHoursDescription;
+
+ /// No description provided for @scheduleDayStartsAt.
+ ///
+ /// In en, this message translates to:
+ /// **'Day starts at'**
+ String get scheduleDayStartsAt;
+
+ /// No description provided for @scheduleDayEndsAt.
+ ///
+ /// In en, this message translates to:
+ /// **'Day ends at'**
+ String get scheduleDayEndsAt;
+
/// No description provided for @sourceCalendar.
///
/// In en, this message translates to:
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index f13f0fb..b16aa50 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -162,6 +162,56 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trayAgendaError => 'Agenda nicht verfügbar';
+ @override
+ String get compactAgendaTitle => 'Agenda';
+
+ @override
+ String get compactAgendaSubtitle => 'Anstehend';
+
+ @override
+ String get compactAgendaOverdue => 'Überfällig';
+
+ @override
+ String get compactAgendaClear => 'Im Moment frei';
+
+ @override
+ String get compactAgendaOpenBusyMax => 'BusyMax öffnen';
+
+ @override
+ String get compactAgendaHide => 'Ausblenden';
+
+ @override
+ String get compactAgendaNewTask => 'Neue Aufgabe';
+
+ @override
+ String get compactAgendaRetry => 'Erneut versuchen';
+
+ @override
+ String get compactAgendaRefresh => 'Aktualisieren';
+
+ @override
+ String get compactAgendaAllDay => 'Ganztägig';
+
+ @override
+ String get compactAgendaDueToday => 'Heute fällig';
+
+ @override
+ String get compactAgendaDueTomorrow => 'Morgen fällig';
+
+ @override
+ String compactAgendaDueOn(String date) {
+ return 'Fällig $date';
+ }
+
+ @override
+ String get compactAgendaMoreOverdue => 'Weitere überfällige Aufgaben laden';
+
+ @override
+ String get agendaLoadMoreOverdue => 'Weitere überfällige Aufgaben laden';
+
+ @override
+ String get agendaLoadMoreNoDate => 'Weitere Aufgaben ohne Datum laden';
+
@override
String get viewDay => 'Tag';
@@ -177,6 +227,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get viewAgenda => 'Agenda';
+ @override
+ String get scheduleSettings => 'Zeitplan';
+
+ @override
+ String get scheduleDisplaySettings => 'Zeitplananzeige';
+
+ @override
+ String get scheduleDisplayHoursDescription =>
+ 'Tages- und Wochenansicht öffnen innerhalb dieser Zeiten. Frühe und späte Einträge erweitern den Bereich bei Bedarf.';
+
+ @override
+ String get scheduleDayStartsAt => 'Tag beginnt um';
+
+ @override
+ String get scheduleDayEndsAt => 'Tag endet um';
+
@override
String get sourceCalendar => 'Kalender';
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index a807995..42c6ae6 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -160,6 +160,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trayAgendaError => 'Agenda unavailable';
+ @override
+ String get compactAgendaTitle => 'Agenda';
+
+ @override
+ String get compactAgendaSubtitle => 'Upcoming';
+
+ @override
+ String get compactAgendaOverdue => 'Overdue';
+
+ @override
+ String get compactAgendaClear => 'Clear for now';
+
+ @override
+ String get compactAgendaOpenBusyMax => 'Open BusyMax';
+
+ @override
+ String get compactAgendaHide => 'Hide';
+
+ @override
+ String get compactAgendaNewTask => 'New task';
+
+ @override
+ String get compactAgendaRetry => 'Retry';
+
+ @override
+ String get compactAgendaRefresh => 'Refresh';
+
+ @override
+ String get compactAgendaAllDay => 'All day';
+
+ @override
+ String get compactAgendaDueToday => 'Due today';
+
+ @override
+ String get compactAgendaDueTomorrow => 'Due tomorrow';
+
+ @override
+ String compactAgendaDueOn(String date) {
+ return 'Due $date';
+ }
+
+ @override
+ String get compactAgendaMoreOverdue => 'Load more overdue tasks';
+
+ @override
+ String get agendaLoadMoreOverdue => 'Load more overdue tasks';
+
+ @override
+ String get agendaLoadMoreNoDate => 'Load more no-date tasks';
+
@override
String get viewDay => 'Day';
@@ -175,6 +225,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get viewAgenda => 'Agenda';
+ @override
+ String get scheduleSettings => 'Schedule';
+
+ @override
+ String get scheduleDisplaySettings => 'Schedule display';
+
+ @override
+ String get scheduleDisplayHoursDescription =>
+ 'Day and Week views open within these hours. Early and late items expand the range when needed.';
+
+ @override
+ String get scheduleDayStartsAt => 'Day starts at';
+
+ @override
+ String get scheduleDayEndsAt => 'Day ends at';
+
@override
String get sourceCalendar => 'Calendar';
diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart
index 7412ba0..7f2d628 100644
--- a/lib/l10n/generated/app_localizations_es.dart
+++ b/lib/l10n/generated/app_localizations_es.dart
@@ -164,6 +164,56 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trayAgendaError => 'Agenda no disponible';
+ @override
+ String get compactAgendaTitle => 'Agenda';
+
+ @override
+ String get compactAgendaSubtitle => 'Próximamente';
+
+ @override
+ String get compactAgendaOverdue => 'Vencidas';
+
+ @override
+ String get compactAgendaClear => 'Libre por ahora';
+
+ @override
+ String get compactAgendaOpenBusyMax => 'Abrir BusyMax';
+
+ @override
+ String get compactAgendaHide => 'Ocultar';
+
+ @override
+ String get compactAgendaNewTask => 'Nueva tarea';
+
+ @override
+ String get compactAgendaRetry => 'Reintentar';
+
+ @override
+ String get compactAgendaRefresh => 'Actualizar';
+
+ @override
+ String get compactAgendaAllDay => 'Todo el día';
+
+ @override
+ String get compactAgendaDueToday => 'Vence hoy';
+
+ @override
+ String get compactAgendaDueTomorrow => 'Vence mañana';
+
+ @override
+ String compactAgendaDueOn(String date) {
+ return 'Vence $date';
+ }
+
+ @override
+ String get compactAgendaMoreOverdue => 'Cargar más tareas vencidas';
+
+ @override
+ String get agendaLoadMoreOverdue => 'Cargar más tareas vencidas';
+
+ @override
+ String get agendaLoadMoreNoDate => 'Cargar más tareas sin fecha';
+
@override
String get viewDay => 'Día';
@@ -179,6 +229,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get viewAgenda => 'Agenda';
+ @override
+ String get scheduleSettings => 'Agenda';
+
+ @override
+ String get scheduleDisplaySettings => 'Visualización de agenda';
+
+ @override
+ String get scheduleDisplayHoursDescription =>
+ 'Las vistas Día y Semana se abren dentro de este horario. Los elementos tempranos y tardíos amplían el intervalo si hace falta.';
+
+ @override
+ String get scheduleDayStartsAt => 'El día empieza a las';
+
+ @override
+ String get scheduleDayEndsAt => 'El día termina a las';
+
@override
String get sourceCalendar => 'Calendario';
diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart
index aa93ade..5b206a6 100644
--- a/lib/l10n/generated/app_localizations_fr.dart
+++ b/lib/l10n/generated/app_localizations_fr.dart
@@ -163,6 +163,56 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trayAgendaError => 'Agenda indisponible';
+ @override
+ String get compactAgendaTitle => 'Agenda';
+
+ @override
+ String get compactAgendaSubtitle => 'À venir';
+
+ @override
+ String get compactAgendaOverdue => 'En retard';
+
+ @override
+ String get compactAgendaClear => 'Libre pour le moment';
+
+ @override
+ String get compactAgendaOpenBusyMax => 'Ouvrir BusyMax';
+
+ @override
+ String get compactAgendaHide => 'Masquer';
+
+ @override
+ String get compactAgendaNewTask => 'Nouvelle tâche';
+
+ @override
+ String get compactAgendaRetry => 'Réessayer';
+
+ @override
+ String get compactAgendaRefresh => 'Actualiser';
+
+ @override
+ String get compactAgendaAllDay => 'Toute la journée';
+
+ @override
+ String get compactAgendaDueToday => 'Échéance aujourd’hui';
+
+ @override
+ String get compactAgendaDueTomorrow => 'Échéance demain';
+
+ @override
+ String compactAgendaDueOn(String date) {
+ return 'Échéance $date';
+ }
+
+ @override
+ String get compactAgendaMoreOverdue => 'Charger plus de tâches en retard';
+
+ @override
+ String get agendaLoadMoreOverdue => 'Charger plus de tâches en retard';
+
+ @override
+ String get agendaLoadMoreNoDate => 'Charger plus de tâches sans date';
+
@override
String get viewDay => 'Jour';
@@ -178,6 +228,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get viewAgenda => 'Agenda';
+ @override
+ String get scheduleSettings => 'Planning';
+
+ @override
+ String get scheduleDisplaySettings => 'Affichage du planning';
+
+ @override
+ String get scheduleDisplayHoursDescription =>
+ 'Les vues Jour et Semaine s’ouvrent dans cette plage horaire. Les éléments tôt ou tardifs l’élargissent si nécessaire.';
+
+ @override
+ String get scheduleDayStartsAt => 'La journée commence à';
+
+ @override
+ String get scheduleDayEndsAt => 'La journée se termine à';
+
@override
String get sourceCalendar => 'Calendrier';
diff --git a/lib/main.dart b/lib/main.dart
index da7171b..5bc4663 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,23 +1,44 @@
+import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:system_theme/system_theme.dart';
+import 'package:window_manager/window_manager.dart';
import 'src/app/app_bootstrap.dart';
import 'src/app/busymax_app.dart';
import 'src/config/build_config.dart';
import 'src/core/logging/redacting_logger.dart';
+import 'src/features/schedule/presentation/compact_agenda_app.dart';
+import 'src/platform/busymax_window_args.dart';
-Future main() async {
+Future main(List args) async {
WidgetsFlutterBinding.ensureInitialized();
await SystemTheme.accentColor.load();
configureLogging();
- runApp(
- ProviderScope(
- overrides: [
- buildConfigProvider.overrideWithValue(BuildConfig.fromEnvironment()),
- ],
- child: const BusyMaxApp(),
- ),
- );
+ final windowController = await WindowController.fromCurrentEngine();
+ final windowArgs = BusyMaxWindowArgs.parse(windowController.arguments);
+ final overrides = [
+ buildConfigProvider.overrideWithValue(BuildConfig.fromEnvironment()),
+ ];
+
+ switch (windowArgs.kind) {
+ case BusyMaxWindowKind.main:
+ runApp(ProviderScope(overrides: overrides, child: const BusyMaxApp()));
+ case BusyMaxWindowKind.compactAgenda:
+ await configureCompactAgendaNativeWindow();
+ runApp(
+ ProviderScope(
+ overrides: overrides,
+ child: BusyMaxCompactAgendaApp(
+ windowController: windowController,
+ windowArgs: windowArgs,
+ ),
+ ),
+ );
+ }
+}
+
+Future configureCompactAgendaNativeWindow() async {
+ await windowManager.ensureInitialized();
}
diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart
index d337551..fdde24e 100644
--- a/lib/src/app/app_bootstrap.dart
+++ b/lib/src/app/app_bootstrap.dart
@@ -31,10 +31,13 @@ import '../microsoft_calendar/microsoft_calendar_api_client.dart';
import '../microsoft_todo/api/microsoft_todo_api_client.dart';
import '../microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart';
import '../microsoft_todo/oauth/microsoft_oauth_service.dart';
+import '../platform/compact_agenda_window_service.dart';
import '../platform/linux_header_bar_service.dart';
import '../platform/linux_window_service.dart';
import '../task_providers/task_provider.dart';
+import '../schedule/schedule_commands.dart';
import '../schedule/schedule_repository.dart';
+import 'app_router.dart';
import 'app_settings.dart';
export '../app/app_settings.dart';
@@ -123,6 +126,12 @@ final linuxHeaderBarServiceProvider = Provider((ref) {
return service;
});
+final compactAgendaWindowServiceProvider = Provider(
+ (ref) {
+ return const CompactAgendaWindowService();
+ },
+);
+
final authRepositoryProvider = Provider((ref) {
return AuthRepository(
oAuth: ref.watch(applicationOAuthServiceProvider),
@@ -275,6 +284,8 @@ final calendarSyncEngineForAccountFactoryProvider =
onConflictBlocked: ref
.read(desktopNotificationServiceProvider)
.notifyConflict,
+ onNotificationScheduleChanged: () =>
+ ref.read(notificationSchedulerProvider).checkNow(),
);
};
});
@@ -335,7 +346,12 @@ authSessionControllerProvider =
});
final calendarRepositoryProvider = Provider((ref) {
- return CalendarRepository(database: ref.watch(databaseProvider));
+ return CalendarRepository(
+ database: ref.watch(databaseProvider),
+ localTimeZone: ref.watch(localTimeZoneProvider),
+ onNotificationScheduleChanged: () =>
+ ref.read(notificationSchedulerProvider).checkNow(),
+ );
});
final scheduleRepositoryProvider = Provider((ref) {
@@ -403,6 +419,8 @@ final tasksRepositoryProvider = Provider((ref) {
accountId: accountId,
apiClient: ref.watch(googleTasksApiClientProvider),
onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request,
+ onNotificationScheduleChanged: () =>
+ ref.read(notificationSchedulerProvider).checkNow(),
);
});
@@ -420,6 +438,8 @@ final tasksRepositoryForAccountProvider =
onMutationQueued: ref
.watch(pendingMutationSyncRequesterForAccountProvider(accountId))
.request,
+ onNotificationScheduleChanged: () =>
+ ref.read(notificationSchedulerProvider).checkNow(),
);
});
@@ -521,12 +541,167 @@ final notificationSchedulerProvider = Provider((ref) {
final scheduler = NotificationScheduler(
database: ref.watch(databaseProvider),
notifications: ref.watch(desktopNotificationServiceProvider),
+ onNotificationActivated: (row) => _openNotificationSource(ref, row),
);
scheduler.start();
ref.onDispose(scheduler.stop);
return scheduler;
});
+final _notificationOpenSequenceProvider = StateProvider((ref) => 0);
+
+Future _openNotificationSource(
+ Ref ref,
+ NotificationScheduleData row,
+) async {
+ return switch (row.sourceType) {
+ 'event' => _openEventNotification(ref, row),
+ 'task' => _openTaskNotification(ref, row),
+ _ => ref.read(linuxWindowServiceProvider).showWindow(),
+ };
+}
+
+Future _openEventNotification(
+ Ref ref,
+ NotificationScheduleData row,
+) async {
+ final database = ref.read(databaseProvider);
+ final event =
+ await (database.select(database.calendarEvents)..where(
+ (table) =>
+ table.accountId.equals(row.accountId) &
+ table.id.equals(row.sourceId) &
+ table.isDeleted.equals(false) &
+ table.isCancelled.equals(false),
+ ))
+ .getSingleOrNull();
+ if (event == null) {
+ await ref.read(linuxWindowServiceProvider).showWindow();
+ return;
+ }
+
+ await _openScheduleItemFromNotification(
+ ref,
+ kind: ScheduleWorkspaceCommandKind.openCalendarEvent,
+ date: _calendarEventCommandDate(event),
+ accountId: event.accountId,
+ sourceId: event.calendarSourceId,
+ itemId: event.id,
+ );
+}
+
+Future _openTaskNotification(
+ Ref ref,
+ NotificationScheduleData row,
+) async {
+ final database = ref.read(databaseProvider);
+ final task =
+ await (database.select(database.tasks)
+ ..where(
+ (table) =>
+ table.accountId.equals(row.accountId) &
+ table.id.equals(row.sourceId) &
+ table.pendingDelete.equals(false),
+ )
+ ..limit(1))
+ .getSingleOrNull();
+ if (task == null) {
+ await ref.read(linuxWindowServiceProvider).showWindow();
+ return;
+ }
+
+ await _openScheduleItemFromNotification(
+ ref,
+ kind: ScheduleWorkspaceCommandKind.openTask,
+ date: _taskCommandDate(task),
+ accountId: task.accountId,
+ sourceId: task.taskListId,
+ itemId: task.id,
+ );
+}
+
+Future _openScheduleItemFromNotification(
+ Ref ref, {
+ required ScheduleWorkspaceCommandKind kind,
+ required DateTime? date,
+ required String accountId,
+ required String sourceId,
+ required String itemId,
+}) async {
+ await ref.read(linuxWindowServiceProvider).showWindow();
+ final sequenceController = ref.read(
+ _notificationOpenSequenceProvider.notifier,
+ );
+ final sequence = sequenceController.state + 1;
+ sequenceController.state = sequence;
+ ref
+ .read(scheduleWorkspaceCommandProvider.notifier)
+ .state = ScheduleWorkspaceCommand(
+ kind,
+ sequence,
+ date: date,
+ accountId: accountId,
+ sourceId: sourceId,
+ itemId: itemId,
+ );
+ ref.read(appRouterProvider).go('/schedule');
+}
+
+DateTime? _calendarEventCommandDate(CalendarEvent event) {
+ if (event.allDay) {
+ return _parseLocalDate(event.startDate);
+ }
+ return _parseProviderDateTime(event.startDateTime, event.startTimeZone);
+}
+
+DateTime? _taskCommandDate(Task task) {
+ return _parseProviderDateTime(
+ task.microsoftDueDateTime,
+ task.microsoftDueTimeZone,
+ ) ??
+ _parseProviderDateTime(
+ task.microsoftReminderDateTime,
+ task.microsoftReminderTimeZone,
+ ) ??
+ _parseProviderDateTime(task.dueUtc, 'UTC');
+}
+
+DateTime? _parseLocalDate(String? value) {
+ if (value == null || value.length < 10) {
+ return null;
+ }
+ return DateTime.tryParse('${value.substring(0, 10)}T00:00:00');
+}
+
+DateTime? _parseProviderDateTime(String? value, String? timeZone) {
+ final parsed = DateTime.tryParse(value ?? '');
+ if (parsed == null) {
+ return null;
+ }
+ if (parsed.isUtc) {
+ return parsed.toLocal();
+ }
+
+ final normalizedZone = timeZone?.trim().toLowerCase();
+ if (normalizedZone == 'utc' ||
+ normalizedZone == 'etc/utc' ||
+ normalizedZone == 'gmt' ||
+ normalizedZone == 'etc/gmt') {
+ return DateTime.utc(
+ parsed.year,
+ parsed.month,
+ parsed.day,
+ parsed.hour,
+ parsed.minute,
+ parsed.second,
+ parsed.millisecond,
+ parsed.microsecond,
+ ).toLocal();
+ }
+
+ return parsed;
+}
+
final dueTodayNotificationProvider = Provider((ref) {
final settings = ref.watch(appSettingsControllerProvider);
final accountId = ref.watch(activeAccountProvider);
diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart
index 57f8d25..cb2d5e9 100644
--- a/lib/src/app/app_router.dart
+++ b/lib/src/app/app_router.dart
@@ -6,7 +6,6 @@ import '../features/auth/data/auth_repository.dart';
import '../features/auth/presentation/sign_in_screen.dart';
import '../features/settings/presentation/settings_screen.dart';
import '../features/schedule/presentation/schedule_workspace.dart';
-import '../features/schedule/presentation/tray_agenda_screen.dart';
import '../schedule/schedule_scope.dart';
import 'app_bootstrap.dart';
@@ -49,10 +48,6 @@ final appRouterProvider = Provider((ref) {
path: '/schedule',
builder: (context, state) => const ScheduleWorkspace(),
),
- GoRoute(
- path: '/tray-agenda',
- builder: (context, state) => const TrayAgendaScreen(),
- ),
GoRoute(
path: '/tasks',
builder: (context, state) =>
diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart
index 6c1f3a7..a12fca7 100644
--- a/lib/src/app/app_settings.dart
+++ b/lib/src/app/app_settings.dart
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
+import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -15,6 +16,9 @@ enum BusyMaxThemeModePreference { system, light, dark }
enum NotificationDetailLevel { private, normal }
+const defaultScheduleDayStartMinute = 7 * 60;
+const defaultScheduleDayEndMinute = 22 * 60;
+
extension BusyMaxThemeModePreferenceX on BusyMaxThemeModePreference {
ThemeMode get themeMode {
return switch (this) {
@@ -47,6 +51,8 @@ class AppSettings {
required this.lastDueTodayNotificationDate,
required this.taskListScheduleVisibility,
required this.scheduleViewMode,
+ required this.scheduleDayStartMinute,
+ required this.scheduleDayEndMinute,
});
factory AppSettings.defaults() {
@@ -62,7 +68,7 @@ class AppSettings {
showTrayIcon: true,
startMinimizedToTray: false,
quitExitsCompletely: true,
- notificationDetailLevel: NotificationDetailLevel.private,
+ notificationDetailLevel: NotificationDetailLevel.normal,
quietHoursEnabled: false,
quietHoursStart: '22:00',
quietHoursEnd: '07:00',
@@ -71,11 +77,41 @@ class AppSettings {
lastDueTodayNotificationDate: null,
taskListScheduleVisibility: {},
scheduleViewMode: ScheduleViewMode.week,
+ scheduleDayStartMinute: defaultScheduleDayStartMinute,
+ scheduleDayEndMinute: defaultScheduleDayEndMinute,
);
}
factory AppSettings.fromJson(Map json) {
final defaults = AppSettings.defaults();
+ final dayStart = _minuteOfDay(
+ json['scheduleDayStartMinute'],
+ defaults.scheduleDayStartMinute,
+ );
+ final dayEnd = _minuteOfDay(
+ json['scheduleDayEndMinute'],
+ defaults.scheduleDayEndMinute,
+ allowEndOfDay: true,
+ );
+ final (
+ scheduleDayStartMinute,
+ scheduleDayEndMinute,
+ ) = _validScheduleDayRange(
+ startMinute: dayStart,
+ endMinute: dayEnd,
+ fallbackStart: defaults.scheduleDayStartMinute,
+ fallbackEnd: defaults.scheduleDayEndMinute,
+ );
+ final detailedNotifications =
+ json['detailedNotifications'] as bool? ??
+ defaults.detailedNotifications;
+ final notificationDetailLevel = detailedNotifications
+ ? NotificationDetailLevel.normal
+ : _enumFromName(
+ NotificationDetailLevel.values,
+ json['notificationDetailLevel'],
+ defaults.notificationDetailLevel,
+ );
return AppSettings(
themeFamily: _enumFromName(
BusyMaxThemeFamily.values,
@@ -107,11 +143,7 @@ class AppSettings {
defaults.startMinimizedToTray,
quitExitsCompletely:
json['quitExitsCompletely'] as bool? ?? defaults.quitExitsCompletely,
- notificationDetailLevel: _enumFromName(
- NotificationDetailLevel.values,
- json['notificationDetailLevel'],
- defaults.notificationDetailLevel,
- ),
+ notificationDetailLevel: notificationDetailLevel,
quietHoursEnabled:
json['quietHoursEnabled'] as bool? ?? defaults.quietHoursEnabled,
quietHoursStart:
@@ -121,9 +153,7 @@ class AppSettings {
redactTaskContentInDiagnostics:
json['redactTaskContentInDiagnostics'] as bool? ??
defaults.redactTaskContentInDiagnostics,
- detailedNotifications:
- json['detailedNotifications'] as bool? ??
- defaults.detailedNotifications,
+ detailedNotifications: detailedNotifications,
lastDueTodayNotificationDate: json['lastDueTodayNotificationDate']
?.toString(),
taskListScheduleVisibility: _boolMap(json['taskListScheduleVisibility']),
@@ -132,6 +162,8 @@ class AppSettings {
json['scheduleViewMode'],
defaults.scheduleViewMode,
),
+ scheduleDayStartMinute: scheduleDayStartMinute,
+ scheduleDayEndMinute: scheduleDayEndMinute,
);
}
@@ -155,6 +187,8 @@ class AppSettings {
final String? lastDueTodayNotificationDate;
final Map taskListScheduleVisibility;
final ScheduleViewMode scheduleViewMode;
+ final int scheduleDayStartMinute;
+ final int scheduleDayEndMinute;
ThemeMode get themeMode => themeModePreference.themeMode;
@@ -180,6 +214,8 @@ class AppSettings {
'lastDueTodayNotificationDate': lastDueTodayNotificationDate,
'taskListScheduleVisibility': taskListScheduleVisibility,
'scheduleViewMode': scheduleViewMode.name,
+ 'scheduleDayStartMinute': scheduleDayStartMinute,
+ 'scheduleDayEndMinute': scheduleDayEndMinute,
};
}
@@ -204,8 +240,19 @@ class AppSettings {
String? lastDueTodayNotificationDate,
Map? taskListScheduleVisibility,
ScheduleViewMode? scheduleViewMode,
+ int? scheduleDayStartMinute,
+ int? scheduleDayEndMinute,
bool clearLastDueTodayNotificationDate = false,
}) {
+ final (
+ resolvedScheduleDayStartMinute,
+ resolvedScheduleDayEndMinute,
+ ) = _validScheduleDayRange(
+ startMinute: scheduleDayStartMinute ?? this.scheduleDayStartMinute,
+ endMinute: scheduleDayEndMinute ?? this.scheduleDayEndMinute,
+ fallbackStart: this.scheduleDayStartMinute,
+ fallbackEnd: this.scheduleDayEndMinute,
+ );
return AppSettings(
themeFamily: themeFamily ?? this.themeFamily,
themeModePreference: themeModePreference ?? this.themeModePreference,
@@ -234,6 +281,8 @@ class AppSettings {
taskListScheduleVisibility:
taskListScheduleVisibility ?? this.taskListScheduleVisibility,
scheduleViewMode: scheduleViewMode ?? this.scheduleViewMode,
+ scheduleDayStartMinute: resolvedScheduleDayStartMinute,
+ scheduleDayEndMinute: resolvedScheduleDayEndMinute,
);
}
@@ -295,6 +344,30 @@ class AppSettingsController extends StateNotifier {
return _save(state.copyWith(scheduleViewMode: mode));
}
+ Future setScheduleDayStartMinute(int minute) {
+ final start = _minuteOfDay(minute, state.scheduleDayStartMinute);
+ final end = start >= state.scheduleDayEndMinute
+ ? math.min(start + 60, 24 * 60)
+ : state.scheduleDayEndMinute;
+ return _save(
+ state.copyWith(scheduleDayStartMinute: start, scheduleDayEndMinute: end),
+ );
+ }
+
+ Future setScheduleDayEndMinute(int minute) {
+ final end = _minuteOfDay(
+ minute,
+ state.scheduleDayEndMinute,
+ allowEndOfDay: true,
+ );
+ final start = end <= state.scheduleDayStartMinute
+ ? math.max(end - 60, 0)
+ : state.scheduleDayStartMinute;
+ return _save(
+ state.copyWith(scheduleDayStartMinute: start, scheduleDayEndMinute: end),
+ );
+ }
+
Future setNotifySyncFailures(bool enabled) {
return _save(state.copyWith(notifySyncFailures: enabled));
}
@@ -332,7 +405,12 @@ class AppSettingsController extends StateNotifier {
}
Future setNotificationDetailLevel(NotificationDetailLevel level) {
- return _save(state.copyWith(notificationDetailLevel: level));
+ return _save(
+ state.copyWith(
+ notificationDetailLevel: level,
+ detailedNotifications: level != NotificationDetailLevel.private,
+ ),
+ );
}
Future setQuietHoursEnabled(bool enabled) {
@@ -344,7 +422,14 @@ class AppSettingsController extends StateNotifier {
}
Future setDetailedNotifications(bool enabled) {
- return _save(state.copyWith(detailedNotifications: enabled));
+ return _save(
+ state.copyWith(
+ detailedNotifications: enabled,
+ notificationDetailLevel: enabled
+ ? NotificationDetailLevel.normal
+ : NotificationDetailLevel.private,
+ ),
+ );
}
Future markDueTodayNotified(String date) {
@@ -416,6 +501,35 @@ T _enumFromName(List values, Object? name, T fallback) {
return fallback;
}
+int _minuteOfDay(Object? value, int fallback, {bool allowEndOfDay = false}) {
+ final minute = switch (value) {
+ int() => value,
+ String() => int.tryParse(value) ?? fallback,
+ _ => fallback,
+ };
+ final max = allowEndOfDay ? 24 * 60 : 24 * 60 - 1;
+ if (minute < 0 || minute > max) {
+ return fallback;
+ }
+ return minute;
+}
+
+(int, int) _validScheduleDayRange({
+ required int startMinute,
+ required int endMinute,
+ required int fallbackStart,
+ required int fallbackEnd,
+}) {
+ if (startMinute < 0 ||
+ startMinute >= 24 * 60 ||
+ endMinute <= 0 ||
+ endMinute > 24 * 60 ||
+ endMinute <= startMinute) {
+ return (fallbackStart, fallbackEnd);
+ }
+ return (startMinute, endMinute);
+}
+
Map _boolMap(Object? value) {
if (value is! Map) {
return {};
diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart
index 6036911..aa11628 100644
--- a/lib/src/app/busymax_about_dialog.dart
+++ b/lib/src/app/busymax_about_dialog.dart
@@ -163,7 +163,7 @@ class _BusyMaxLogoState extends State<_BusyMaxLogo> {
}
Future _loadLogoBytes() async {
- const assetPath = 'assets/branding/busymax-logo.png';
+ const assetPath = 'assets/branding/busymax-logo.svg';
try {
final data = await rootBundle.load(assetPath);
return Uint8List.view(
diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart
index 1ad7db0..a678078 100644
--- a/lib/src/app/busymax_app.dart
+++ b/lib/src/app/busymax_app.dart
@@ -8,6 +8,7 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart';
import '../platform/busymax_tray_service.dart';
import '../platform/gtk_font_service.dart';
import '../platform/linux_header_bar_service.dart';
+import '../platform/main_window_command_bridge.dart';
import 'app_bootstrap.dart';
import 'app_router.dart';
import '../../l10n/generated/app_localizations.dart';
@@ -91,8 +92,10 @@ class _BusyMaxAppState extends ConsumerState {
quitBusyMax: l10n.exit,
),
);
- return _BusyMaxWindowCornerClip(
- child: child ?? const SizedBox.shrink(),
+ return MainWindowCommandBridge(
+ child: _BusyMaxWindowCornerClip(
+ child: child ?? const SizedBox.shrink(),
+ ),
);
},
routerConfig: router,
@@ -134,6 +137,7 @@ class _BusyMaxAppState extends ConsumerState {
await service.setSidebarWidth(BusyMaxSizes.sidebarWidth);
await service.setTheme(
BusyMaxHeaderBarTheme(
+ windowBackgroundColor: colors.window,
backgroundColor: colors.view,
sidebarBackgroundColor: colors.sidebar,
foregroundColor: colors.foreground,
@@ -178,10 +182,12 @@ class _BusyMaxAppState extends ConsumerState {
return;
}
_lastTrayEnabled = trayEnabled;
+ final compactAgendaWindows = ref.read(compactAgendaWindowServiceProvider);
final tray = _trayService ??= BusyMaxTrayService(
windowService: windowService,
labels: labels,
- onOpenAgenda: () => _openTrayAgenda(ref),
+ onOpenAgenda: compactAgendaWindows.toggle,
+ onBeforeQuit: compactAgendaWindows.closeIfOpen,
);
if (trayEnabled) {
unawaited(tray.start());
@@ -189,10 +195,6 @@ class _BusyMaxAppState extends ConsumerState {
unawaited(tray.stop());
}
}
-
- Future _openTrayAgenda(WidgetRef ref) async {
- ref.read(appRouterProvider).go('/tray-agenda');
- }
}
class _BusyMaxWindowCornerClip extends StatelessWidget {
diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart
index 0d5aee3..bbd7799 100644
--- a/lib/src/app/busymax_design.dart
+++ b/lib/src/app/busymax_design.dart
@@ -54,14 +54,27 @@ abstract final class BusyMaxSizes {
abstract final class BusyMaxElevation {
static const double surface = 1;
static const double popover = 6;
+ static const double tooltip = 10;
+ static const double window = 12;
}
abstract final class BusyMaxShadow {
- static const double floatingBlur = 18;
- static const Offset floatingOffset = Offset(0, 6);
+ static const double floatingBlur = 24;
+ static const Offset floatingOffset = Offset(0, 8);
+ static const double windowMargin = 32;
+
+ static Color _scaleAlpha(Color color, double scale) {
+ return color.withValues(
+ alpha: (color.a * scale).clamp(0.0, 1.0).toDouble(),
+ );
+ }
static Color floatingColor(BuildContext context) {
- return Theme.of(context).shadowColor.withValues(alpha: 0.32);
+ return BusyMaxSurfaceColors.of(context).shade;
+ }
+
+ static Color tooltipColor(BuildContext context) {
+ return _scaleAlpha(floatingColor(context), 1.45);
}
static List floatingShadows(Color color) {
@@ -69,6 +82,73 @@ abstract final class BusyMaxShadow {
BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset),
];
}
+
+ static List tooltipShadows(Color color) {
+ return [
+ BoxShadow(
+ color: _scaleAlpha(color, 1.45),
+ blurRadius: 30,
+ offset: const Offset(0, 10),
+ ),
+ BoxShadow(
+ color: _scaleAlpha(color, 0.9),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ];
+ }
+
+ static List floatingShadowsFor(BuildContext context) {
+ return floatingShadows(floatingColor(context));
+ }
+
+ static List tooltipShadowsFor(BuildContext context) {
+ return tooltipShadows(floatingColor(context));
+ }
+
+ static List windowShadows(Color color) {
+ return [
+ BoxShadow(
+ color: color.withValues(alpha: color.a * 0.75),
+ blurRadius: 22,
+ offset: const Offset(0, 10),
+ ),
+ BoxShadow(
+ color: color.withValues(alpha: color.a * 0.45),
+ blurRadius: 10,
+ offset: const Offset(0, 3),
+ ),
+ BoxShadow(
+ color: color.withValues(alpha: color.a * 0.25),
+ blurRadius: 3,
+ offset: const Offset(0, 1),
+ ),
+ ];
+ }
+
+ static List windowShadowsFor(BuildContext context) {
+ return windowShadows(floatingColor(context));
+ }
+
+ static List edgeShadows(Color color, {required bool below}) {
+ return [
+ BoxShadow(
+ color: color,
+ blurRadius: floatingBlur / 2,
+ offset: Offset(
+ 0,
+ below ? floatingOffset.dy / 2 : -floatingOffset.dy / 2,
+ ),
+ ),
+ ];
+ }
+
+ static List edgeShadowsFor(
+ BuildContext context, {
+ required bool below,
+ }) {
+ return edgeShadows(floatingColor(context), below: below);
+ }
}
enum BusyMaxPopoverArrowSide { top, bottom }
@@ -98,8 +178,8 @@ class BusyMaxPopoverSurface extends StatelessWidget {
alignment: arrowAlignment.clamp(0.0, 1.0).toDouble(),
),
color: color,
- elevation: BusyMaxElevation.popover,
- shadowColor: BusyMaxShadow.floatingColor(context),
+ elevation: BusyMaxElevation.tooltip,
+ shadowColor: BusyMaxShadow.tooltipColor(context),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: EdgeInsets.only(
@@ -774,6 +854,7 @@ class BusyMaxActionRow extends StatelessWidget {
this.leading,
this.trailing,
this.onTap,
+ this.onPointerDown,
this.enabled = true,
this.tooltip,
this.destructive = false,
@@ -788,6 +869,7 @@ class BusyMaxActionRow extends StatelessWidget {
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
+ final ValueChanged? onPointerDown;
final bool enabled;
final String? tooltip;
final bool destructive;
@@ -820,13 +902,488 @@ class BusyMaxActionRow extends StatelessWidget {
onTap: enabled ? onTap : null,
);
+ final trackedRow = onPointerDown == null
+ ? row
+ : Listener(
+ onPointerDown: enabled
+ ? (event) => onPointerDown!(event.position)
+ : null,
+ child: row,
+ );
+
if (enabled || tooltip == null) {
- return row;
+ return trackedRow;
}
return Tooltip(
message: tooltip!,
- child: Opacity(opacity: 0.6, child: IgnorePointer(child: row)),
+ child: Opacity(opacity: 0.6, child: IgnorePointer(child: trackedRow)),
+ );
+ }
+}
+
+class BusyMaxCategoryEditorRow extends StatelessWidget {
+ const BusyMaxCategoryEditorRow({
+ super.key,
+ required this.title,
+ required this.addLabel,
+ required this.categories,
+ required this.suggestions,
+ required this.adding,
+ required this.controller,
+ required this.onAddPressed,
+ required this.onSubmitted,
+ required this.onCancelAdding,
+ required this.onDeleted,
+ this.inputKey,
+ });
+
+ final String title;
+ final String addLabel;
+ final List categories;
+ final List suggestions;
+ final bool adding;
+ final TextEditingController controller;
+ final VoidCallback onAddPressed;
+ final ValueChanged onSubmitted;
+ final VoidCallback onCancelAdding;
+ final ValueChanged onDeleted;
+ final Key? inputKey;
+
+ @override
+ Widget build(BuildContext context) {
+ final visibleSuggestions = [
+ for (final suggestion in suggestions)
+ if (suggestion.trim().isNotEmpty && !categories.contains(suggestion))
+ suggestion,
+ ];
+ return BusyMaxActionRow(
+ title: title,
+ leading: const Icon(Icons.sell_outlined),
+ subtitleWidget: Padding(
+ padding: const EdgeInsets.only(top: BusyMaxSpacing.xs),
+ child: Wrap(
+ spacing: BusyMaxSpacing.xs,
+ runSpacing: BusyMaxSpacing.xs,
+ crossAxisAlignment: WrapCrossAlignment.center,
+ children: [
+ for (final category in categories)
+ _BusyMaxCategoryChip(
+ label: category,
+ onDeleted: () => onDeleted(category),
+ ),
+ if (adding) ...[
+ _BusyMaxCategoryInputChip(
+ controller: controller,
+ hintText: addLabel,
+ suggestions: visibleSuggestions,
+ inputKey: inputKey,
+ onSubmitted: onSubmitted,
+ onCancel: onCancelAdding,
+ ),
+ ] else
+ _BusyMaxAddCategoryChip(label: addLabel, onPressed: onAddPressed),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _BusyMaxCategoryChip extends StatelessWidget {
+ const _BusyMaxCategoryChip({required this.label, required this.onDeleted});
+
+ final String label;
+ final VoidCallback onDeleted;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final surfaceColors = BusyMaxSurfaceColors.of(context);
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: surfaceColors.control,
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ ),
+ child: Padding(
+ padding: const EdgeInsetsDirectional.only(
+ start: BusyMaxSpacing.md,
+ end: BusyMaxSpacing.xs,
+ top: BusyMaxSpacing.xs,
+ bottom: BusyMaxSpacing.xs,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 160),
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelLarge,
+ ),
+ ),
+ const SizedBox(width: BusyMaxSpacing.xs),
+ _BusyMaxCategoryIconAction(
+ icon: YaruIcons.window_close,
+ tooltip:
+ '${MaterialLocalizations.of(context).deleteButtonTooltip} $label',
+ color: colorScheme.onSurfaceVariant,
+ onPressed: onDeleted,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _BusyMaxAddCategoryChip extends StatelessWidget {
+ const _BusyMaxAddCategoryChip({required this.label, required this.onPressed});
+
+ final String label;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Material(
+ color: Colors.transparent,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ ),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ hoverColor: busyMaxEditorRowHoverColor(context),
+ onTap: onPressed,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: BusyMaxSpacing.md,
+ vertical: BusyMaxSpacing.xs,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ YaruIcons.plus,
+ size: BusyMaxSizes.iconSm,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: BusyMaxSpacing.xs),
+ Text(
+ label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _BusyMaxCategoryInputChip extends StatefulWidget {
+ const _BusyMaxCategoryInputChip({
+ required this.controller,
+ required this.hintText,
+ required this.suggestions,
+ this.inputKey,
+ required this.onSubmitted,
+ required this.onCancel,
+ });
+
+ final TextEditingController controller;
+ final String hintText;
+ final List suggestions;
+ final Key? inputKey;
+ final ValueChanged onSubmitted;
+ final VoidCallback onCancel;
+
+ @override
+ State<_BusyMaxCategoryInputChip> createState() =>
+ _BusyMaxCategoryInputChipState();
+}
+
+class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> {
+ late final FocusNode _focusNode;
+
+ @override
+ void initState() {
+ super.initState();
+ _focusNode = FocusNode();
+ }
+
+ @override
+ void dispose() {
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final surfaceColors = BusyMaxSurfaceColors.of(context);
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ color: surfaceColors.control,
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ ),
+ child: Padding(
+ padding: const EdgeInsetsDirectional.only(
+ start: BusyMaxSpacing.md,
+ end: BusyMaxSpacing.xs,
+ ),
+ child: SizedBox(
+ width: 180,
+ height: 30,
+ child: Row(
+ children: [
+ Expanded(
+ child: RawAutocomplete(
+ textEditingController: widget.controller,
+ focusNode: _focusNode,
+ displayStringForOption: (option) => option,
+ optionsViewOpenDirection: OptionsViewOpenDirection.down,
+ optionsBuilder: _categoryOptionsFor,
+ onSelected: widget.onSubmitted,
+ fieldViewBuilder:
+ (context, controller, focusNode, onFieldSubmitted) {
+ return TextField(
+ key: widget.inputKey,
+ controller: controller,
+ focusNode: focusNode,
+ autofocus: true,
+ decoration: busyMaxDropdownDecoration().copyWith(
+ hintText: widget.hintText,
+ ),
+ textInputAction: TextInputAction.done,
+ onSubmitted: _submitTypedCategory,
+ );
+ },
+ optionsViewBuilder: (context, onSelected, options) {
+ return _BusyMaxCategoryAutocompleteOptions(
+ options: options.toList(growable: false),
+ onSelected: onSelected,
+ );
+ },
+ ),
+ ),
+ _BusyMaxCategoryIconAction(
+ icon: YaruIcons.checkmark,
+ tooltip: MaterialLocalizations.of(context).okButtonLabel,
+ color: colorScheme.onSurfaceVariant,
+ onPressed: () => _submitTypedCategory(widget.controller.text),
+ ),
+ const SizedBox(width: BusyMaxSpacing.xs),
+ _BusyMaxCategoryIconAction(
+ icon: YaruIcons.window_close,
+ tooltip: MaterialLocalizations.of(context).cancelButtonLabel,
+ color: colorScheme.onSurfaceVariant,
+ onPressed: widget.onCancel,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Iterable _categoryOptionsFor(TextEditingValue value) {
+ final query = value.text.trim().toLowerCase();
+ if (query.isEmpty) {
+ return const [];
+ }
+ final matching = [
+ for (final suggestion in widget.suggestions)
+ if (suggestion.toLowerCase().contains(query)) suggestion,
+ ];
+ matching.sort((left, right) {
+ final leftLower = left.toLowerCase();
+ final rightLower = right.toLowerCase();
+ final leftStarts = leftLower.startsWith(query);
+ final rightStarts = rightLower.startsWith(query);
+ if (leftStarts != rightStarts) {
+ return leftStarts ? -1 : 1;
+ }
+ return leftLower.compareTo(rightLower);
+ });
+ return matching.take(8);
+ }
+
+ void _submitTypedCategory(String value) {
+ final trimmed = value.trim();
+ if (trimmed.isEmpty) {
+ return;
+ }
+ String? existing;
+ for (final suggestion in widget.suggestions) {
+ if (suggestion.toLowerCase() == trimmed.toLowerCase()) {
+ existing = suggestion;
+ break;
+ }
+ }
+ widget.onSubmitted(existing ?? trimmed);
+ }
+}
+
+class _BusyMaxCategoryIconAction extends StatelessWidget {
+ const _BusyMaxCategoryIconAction({
+ required this.icon,
+ required this.tooltip,
+ required this.color,
+ required this.onPressed,
+ });
+
+ static const _size = 22.0;
+
+ final IconData icon;
+ final String tooltip;
+ final Color color;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final surfaceColors = BusyMaxSurfaceColors.of(context);
+ return Tooltip(
+ message: tooltip,
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ hoverColor: surfaceColors.controlHover,
+ focusColor: surfaceColors.controlHover,
+ highlightColor: surfaceColors.controlActive,
+ splashColor: Colors.transparent,
+ onTap: onPressed,
+ child: SizedBox.square(
+ dimension: _size,
+ child: Icon(icon, size: BusyMaxSizes.iconSm, color: color),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _BusyMaxCategoryAutocompleteOptions extends StatelessWidget {
+ const _BusyMaxCategoryAutocompleteOptions({
+ required this.options,
+ required this.onSelected,
+ });
+
+ final List options;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ if (options.isEmpty) {
+ return const SizedBox.shrink();
+ }
+ final popupTheme = Theme.of(context).popupMenuTheme;
+ final colorScheme = Theme.of(context).colorScheme;
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final width = constraints.maxWidth.isFinite
+ ? constraints.maxWidth
+ : 180.0;
+ final labelWidth = (width - BusyMaxSpacing.md * 2).clamp(0.0, width);
+ return Align(
+ alignment: Alignment.topLeft,
+ child: Material(
+ color: popupTheme.color ?? colorScheme.surfaceContainerHigh,
+ elevation: BusyMaxElevation.popover,
+ shadowColor: BusyMaxShadow.floatingColor(context),
+ surfaceTintColor: Colors.transparent,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ ),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minWidth: width,
+ maxWidth: width,
+ maxHeight: 240,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: ListView.builder(
+ shrinkWrap: true,
+ padding: EdgeInsets.zero,
+ itemCount: options.length,
+ itemBuilder: (context, index) {
+ final option = options[index];
+ return _BusyMaxCategoryAutocompleteOption(
+ width: width,
+ labelWidth: labelWidth,
+ option: option,
+ onSelected: onSelected,
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _BusyMaxCategoryAutocompleteOption extends StatelessWidget {
+ const _BusyMaxCategoryAutocompleteOption({
+ required this.width,
+ required this.labelWidth,
+ required this.option,
+ required this.onSelected,
+ });
+
+ final double width;
+ final double labelWidth;
+ final String option;
+ final ValueChanged onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return SizedBox(
+ width: width,
+ height: 36,
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
+ hoverColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.08),
+ focusColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.08),
+ highlightColor: colorScheme.onSurfaceVariant.withValues(alpha: 0.12),
+ splashColor: Colors.transparent,
+ onTap: () => onSelected(option),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md),
+ child: Align(
+ alignment: AlignmentDirectional.centerStart,
+ child: SizedBox(
+ width: labelWidth,
+ child: Text(
+ option,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ softWrap: false,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: colorScheme.onSurface,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
);
}
}
@@ -1394,6 +1951,141 @@ class BusyMaxEditorHeader extends StatelessWidget {
}
}
+class BusyMaxTimeModeRow extends StatelessWidget {
+ const BusyMaxTimeModeRow({
+ super.key,
+ required this.allDay,
+ required this.onChanged,
+ });
+
+ final bool allDay;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = context.l10n;
+ return Padding(
+ padding: const EdgeInsets.all(BusyMaxSpacing.xs),
+ child: Row(
+ children: [
+ Expanded(
+ child: _BusyMaxTimeModeButton(
+ label: l10n.allDay,
+ selected: allDay,
+ onPressed: () => onChanged(true),
+ ),
+ ),
+ const SizedBox(width: BusyMaxSpacing.xs),
+ Expanded(
+ child: _BusyMaxTimeModeButton(
+ label: l10n.timeSlot,
+ selected: !allDay,
+ onPressed: () => onChanged(false),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _BusyMaxTimeModeButton extends StatelessWidget {
+ const _BusyMaxTimeModeButton({
+ required this.label,
+ required this.selected,
+ required this.onPressed,
+ });
+
+ final String label;
+ final bool selected;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final surfaceColors = BusyMaxSurfaceColors.of(context);
+ final borderRadius = BorderRadius.circular(BusyMaxRadius.headerButton);
+ return Material(
+ color: selected ? surfaceColors.controlHover : Colors.transparent,
+ borderRadius: borderRadius,
+ child: InkWell(
+ borderRadius: borderRadius,
+ onTap: selected ? null : onPressed,
+ child: SizedBox(
+ height: BusyMaxSizes.pushButtonHeight,
+ child: Center(
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: selected
+ ? colorScheme.onSurface
+ : colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class BusyMaxModalEditorScaffold extends StatelessWidget {
+ const BusyMaxModalEditorScaffold({
+ super.key,
+ required this.title,
+ required this.cancelLabel,
+ required this.saveLabel,
+ required this.onCancel,
+ required this.onSave,
+ required this.children,
+ this.saving = false,
+ this.contentMaxWidth = 640,
+ });
+
+ final String title;
+ final String cancelLabel;
+ final String saveLabel;
+ final VoidCallback onCancel;
+ final VoidCallback? onSave;
+ final bool saving;
+ final double contentMaxWidth;
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ BusyMaxEditorHeader(
+ title: title,
+ cancelLabel: cancelLabel,
+ saveLabel: saveLabel,
+ onCancel: onCancel,
+ onSave: onSave,
+ saving: saving,
+ ),
+ const SizedBox(height: BusyMaxSpacing.headerInset),
+ Flexible(
+ child: SingleChildScrollView(
+ child: BusyMaxClamp(
+ maxWidth: contentMaxWidth,
+ margin: EdgeInsets.zero,
+ padding: const EdgeInsets.symmetric(
+ horizontal: BusyMaxSpacing.lg,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: children,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
class BusyMaxDialogCloseButton extends StatelessWidget {
const BusyMaxDialogCloseButton({
super.key,
diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart
index 4511427..455c90b 100644
--- a/lib/src/app/busymax_dialogs.dart
+++ b/lib/src/app/busymax_dialogs.dart
@@ -48,9 +48,11 @@ Future showBusyMaxTextPrompt(
required String actionLabel,
String? initialValue,
String? message,
+ Color? barrierColor,
}) {
return showDialog(
context: context,
+ barrierColor: barrierColor,
builder: (context) => BusyMaxPromptDialog(
title: title,
label: label,
@@ -67,9 +69,11 @@ Future showBusyMaxConfirm(
required String message,
required String confirmLabel,
bool destructive = false,
+ Color? barrierColor,
}) async {
final confirmed = await showDialog(
context: context,
+ barrierColor: barrierColor,
builder: (context) => BusyMaxConfirmDialog(
title: title,
message: message,
diff --git a/lib/src/app/busymax_yaru_theme.dart b/lib/src/app/busymax_yaru_theme.dart
index fa4c843..eb0d85e 100644
--- a/lib/src/app/busymax_yaru_theme.dart
+++ b/lib/src/app/busymax_yaru_theme.dart
@@ -408,7 +408,7 @@ class BusyMaxYaruTheme {
decoration: BoxDecoration(
color: colors.popover,
borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton),
- boxShadow: BusyMaxShadow.floatingShadows(colors.shade),
+ boxShadow: BusyMaxShadow.tooltipShadows(colors.shade),
),
padding: const EdgeInsets.symmetric(
horizontal: BusyMaxSpacing.tooltipHorizontal,
diff --git a/lib/src/calendar_providers/calendar_sync_dto.dart b/lib/src/calendar_providers/calendar_sync_dto.dart
index 5f17b01..277694d 100644
--- a/lib/src/calendar_providers/calendar_sync_dto.dart
+++ b/lib/src/calendar_providers/calendar_sync_dto.dart
@@ -58,6 +58,7 @@ class CalendarEventDto {
this.recurrenceJson,
this.remindersJson,
this.attendeesJson,
+ this.categoriesJson,
this.organizerJson,
this.creatorJson,
this.colorId,
@@ -95,6 +96,7 @@ class CalendarEventDto {
final Object? recurrenceJson;
final Object? remindersJson;
final Object? attendeesJson;
+ final Object? categoriesJson;
final Object? organizerJson;
final Object? creatorJson;
final String? colorId;
diff --git a/lib/src/core/time/provider_date_time.dart b/lib/src/core/time/provider_date_time.dart
new file mode 100644
index 0000000..34d7109
--- /dev/null
+++ b/lib/src/core/time/provider_date_time.dart
@@ -0,0 +1,67 @@
+DateTime? providerDateTimeAsLocal(String? value, String? timeZone) {
+ final parsed = DateTime.tryParse(value ?? '');
+ if (parsed == null) {
+ return null;
+ }
+ if (value == null || !value.contains('T')) {
+ return parsed;
+ }
+ if (parsed.isUtc) {
+ return parsed.toLocal();
+ }
+ if (isUtcTimeZone(timeZone)) {
+ return DateTime.utc(
+ parsed.year,
+ parsed.month,
+ parsed.day,
+ parsed.hour,
+ parsed.minute,
+ parsed.second,
+ parsed.millisecond,
+ parsed.microsecond,
+ ).toLocal();
+ }
+ return parsed;
+}
+
+DateTime? providerDateTimeAsUtcInstant(String? value, String? timeZone) {
+ final parsed = DateTime.tryParse(value ?? '');
+ if (parsed == null) {
+ return null;
+ }
+ if (value == null || !value.contains('T')) {
+ return parsed.toUtc();
+ }
+ if (parsed.isUtc) {
+ return parsed.toUtc();
+ }
+ if (isUtcTimeZone(timeZone)) {
+ return DateTime.utc(
+ parsed.year,
+ parsed.month,
+ parsed.day,
+ parsed.hour,
+ parsed.minute,
+ parsed.second,
+ parsed.millisecond,
+ parsed.microsecond,
+ );
+ }
+ return parsed.toUtc();
+}
+
+bool providerDateTimeIsInstant(String? value, String? timeZone) {
+ final parsed = DateTime.tryParse(value ?? '');
+ if (parsed == null || value == null || !value.contains('T')) {
+ return false;
+ }
+ return parsed.isUtc || isUtcTimeZone(timeZone);
+}
+
+bool isUtcTimeZone(String? timeZone) {
+ final normalizedZone = timeZone?.trim().toLowerCase();
+ return normalizedZone == 'utc' ||
+ normalizedZone == 'etc/utc' ||
+ normalizedZone == 'gmt' ||
+ normalizedZone == 'etc/gmt';
+}
diff --git a/lib/src/db/app_database.dart b/lib/src/db/app_database.dart
index bbbcfb5..abce805 100644
--- a/lib/src/db/app_database.dart
+++ b/lib/src/db/app_database.dart
@@ -37,6 +37,13 @@ class AppDatabase extends _$AppDatabase {
factory AppDatabase.open() => AppDatabase(openBusyMaxDatabase());
+ factory AppDatabase.memoryForTests() => AppDatabase(
+ DatabaseConnection(
+ NativeDatabase.memory(),
+ closeStreamsSynchronously: true,
+ ),
+ );
+
@override
int get schemaVersion => latestSchemaVersion;
diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart
index 31b152d..cdc1152 100644
--- a/lib/src/db/app_database.g.dart
+++ b/lib/src/db/app_database.g.dart
@@ -8210,6 +8210,17 @@ class $CalendarEventsTable extends CalendarEvents
type: DriftSqlType.string,
requiredDuringInsert: false,
);
+ static const VerificationMeta _categoriesJsonMeta = const VerificationMeta(
+ 'categoriesJson',
+ );
+ @override
+ late final GeneratedColumn categoriesJson = GeneratedColumn(
+ 'categories_json',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ );
static const VerificationMeta _organizerJsonMeta = const VerificationMeta(
'organizerJson',
);
@@ -8453,6 +8464,7 @@ class $CalendarEventsTable extends CalendarEvents
recurrenceJson,
remindersJson,
attendeesJson,
+ categoriesJson,
organizerJson,
creatorJson,
colorId,
@@ -8676,6 +8688,15 @@ class $CalendarEventsTable extends CalendarEvents
),
);
}
+ if (data.containsKey('categories_json')) {
+ context.handle(
+ _categoriesJsonMeta,
+ categoriesJson.isAcceptableOrUnknown(
+ data['categories_json']!,
+ _categoriesJsonMeta,
+ ),
+ );
+ }
if (data.containsKey('organizer_json')) {
context.handle(
_organizerJsonMeta,
@@ -8928,6 +8949,10 @@ class $CalendarEventsTable extends CalendarEvents
DriftSqlType.string,
data['${effectivePrefix}attendees_json'],
),
+ categoriesJson: attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}categories_json'],
+ ),
organizerJson: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}organizer_json'],
@@ -9037,6 +9062,7 @@ class CalendarEvent extends DataClass implements Insertable {
final String? recurrenceJson;
final String? remindersJson;
final String? attendeesJson;
+ final String? categoriesJson;
final String? organizerJson;
final String? creatorJson;
final String? colorId;
@@ -9080,6 +9106,7 @@ class CalendarEvent extends DataClass implements Insertable {
this.recurrenceJson,
this.remindersJson,
this.attendeesJson,
+ this.categoriesJson,
this.organizerJson,
this.creatorJson,
this.colorId,
@@ -9160,6 +9187,9 @@ class CalendarEvent extends DataClass implements Insertable {
if (!nullToAbsent || attendeesJson != null) {
map['attendees_json'] = Variable(attendeesJson);
}
+ if (!nullToAbsent || categoriesJson != null) {
+ map['categories_json'] = Variable(categoriesJson);
+ }
if (!nullToAbsent || organizerJson != null) {
map['organizer_json'] = Variable(organizerJson);
}
@@ -9265,6 +9295,9 @@ class CalendarEvent extends DataClass implements Insertable {
attendeesJson: attendeesJson == null && nullToAbsent
? const Value.absent()
: Value(attendeesJson),
+ categoriesJson: categoriesJson == null && nullToAbsent
+ ? const Value.absent()
+ : Value(categoriesJson),
organizerJson: organizerJson == null && nullToAbsent
? const Value.absent()
: Value(organizerJson),
@@ -9350,6 +9383,7 @@ class CalendarEvent extends DataClass implements Insertable {
recurrenceJson: serializer.fromJson(json['recurrenceJson']),
remindersJson: serializer.fromJson(json['remindersJson']),
attendeesJson: serializer.fromJson(json['attendeesJson']),
+ categoriesJson: serializer.fromJson(json['categoriesJson']),
organizerJson: serializer.fromJson(json['organizerJson']),
creatorJson: serializer.fromJson(json['creatorJson']),
colorId: serializer.fromJson(json['colorId']),
@@ -9404,6 +9438,7 @@ class CalendarEvent extends DataClass implements Insertable {
'recurrenceJson': serializer.toJson(recurrenceJson),
'remindersJson': serializer.toJson(remindersJson),
'attendeesJson': serializer.toJson(attendeesJson),
+ 'categoriesJson': serializer.toJson(categoriesJson),
'organizerJson': serializer.toJson(organizerJson),
'creatorJson': serializer.toJson(creatorJson),
'colorId': serializer.toJson(colorId),
@@ -9450,6 +9485,7 @@ class CalendarEvent extends DataClass implements Insertable {
Value recurrenceJson = const Value.absent(),
Value remindersJson = const Value.absent(),
Value attendeesJson = const Value.absent(),
+ Value categoriesJson = const Value.absent(),
Value organizerJson = const Value.absent(),
Value creatorJson = const Value.absent(),
Value colorId = const Value.absent(),
@@ -9509,6 +9545,9 @@ class CalendarEvent extends DataClass implements Insertable {
attendeesJson: attendeesJson.present
? attendeesJson.value
: this.attendeesJson,
+ categoriesJson: categoriesJson.present
+ ? categoriesJson.value
+ : this.categoriesJson,
organizerJson: organizerJson.present
? organizerJson.value
: this.organizerJson,
@@ -9596,6 +9635,9 @@ class CalendarEvent extends DataClass implements Insertable {
attendeesJson: data.attendeesJson.present
? data.attendeesJson.value
: this.attendeesJson,
+ categoriesJson: data.categoriesJson.present
+ ? data.categoriesJson.value
+ : this.categoriesJson,
organizerJson: data.organizerJson.present
? data.organizerJson.value
: this.organizerJson,
@@ -9670,6 +9712,7 @@ class CalendarEvent extends DataClass implements Insertable {
..write('recurrenceJson: $recurrenceJson, ')
..write('remindersJson: $remindersJson, ')
..write('attendeesJson: $attendeesJson, ')
+ ..write('categoriesJson: $categoriesJson, ')
..write('organizerJson: $organizerJson, ')
..write('creatorJson: $creatorJson, ')
..write('colorId: $colorId, ')
@@ -9718,6 +9761,7 @@ class CalendarEvent extends DataClass implements Insertable {
recurrenceJson,
remindersJson,
attendeesJson,
+ categoriesJson,
organizerJson,
creatorJson,
colorId,
@@ -9765,6 +9809,7 @@ class CalendarEvent extends DataClass implements Insertable {
other.recurrenceJson == this.recurrenceJson &&
other.remindersJson == this.remindersJson &&
other.attendeesJson == this.attendeesJson &&
+ other.categoriesJson == this.categoriesJson &&
other.organizerJson == this.organizerJson &&
other.creatorJson == this.creatorJson &&
other.colorId == this.colorId &&
@@ -9810,6 +9855,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
final Value recurrenceJson;
final Value remindersJson;
final Value attendeesJson;
+ final Value categoriesJson;
final Value organizerJson;
final Value creatorJson;
final Value colorId;
@@ -9854,6 +9900,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
this.recurrenceJson = const Value.absent(),
this.remindersJson = const Value.absent(),
this.attendeesJson = const Value.absent(),
+ this.categoriesJson = const Value.absent(),
this.organizerJson = const Value.absent(),
this.creatorJson = const Value.absent(),
this.colorId = const Value.absent(),
@@ -9899,6 +9946,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
this.recurrenceJson = const Value.absent(),
this.remindersJson = const Value.absent(),
this.attendeesJson = const Value.absent(),
+ this.categoriesJson = const Value.absent(),
this.organizerJson = const Value.absent(),
this.creatorJson = const Value.absent(),
this.colorId = const Value.absent(),
@@ -9952,6 +10000,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
Expression? recurrenceJson,
Expression? remindersJson,
Expression? attendeesJson,
+ Expression? categoriesJson,
Expression? organizerJson,
Expression? creatorJson,
Expression? colorId,
@@ -10000,6 +10049,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
if (recurrenceJson != null) 'recurrence_json': recurrenceJson,
if (remindersJson != null) 'reminders_json': remindersJson,
if (attendeesJson != null) 'attendees_json': attendeesJson,
+ if (categoriesJson != null) 'categories_json': categoriesJson,
if (organizerJson != null) 'organizer_json': organizerJson,
if (creatorJson != null) 'creator_json': creatorJson,
if (colorId != null) 'color_id': colorId,
@@ -10048,6 +10098,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
Value? recurrenceJson,
Value? remindersJson,
Value? attendeesJson,
+ Value? categoriesJson,
Value? organizerJson,
Value? creatorJson,
Value? colorId,
@@ -10095,6 +10146,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
recurrenceJson: recurrenceJson ?? this.recurrenceJson,
remindersJson: remindersJson ?? this.remindersJson,
attendeesJson: attendeesJson ?? this.attendeesJson,
+ categoriesJson: categoriesJson ?? this.categoriesJson,
organizerJson: organizerJson ?? this.organizerJson,
creatorJson: creatorJson ?? this.creatorJson,
colorId: colorId ?? this.colorId,
@@ -10194,6 +10246,9 @@ class CalendarEventsCompanion extends UpdateCompanion {
if (attendeesJson.present) {
map['attendees_json'] = Variable(attendeesJson.value);
}
+ if (categoriesJson.present) {
+ map['categories_json'] = Variable(categoriesJson.value);
+ }
if (organizerJson.present) {
map['organizer_json'] = Variable(organizerJson.value);
}
@@ -10285,6 +10340,7 @@ class CalendarEventsCompanion extends UpdateCompanion {
..write('recurrenceJson: $recurrenceJson, ')
..write('remindersJson: $remindersJson, ')
..write('attendeesJson: $attendeesJson, ')
+ ..write('categoriesJson: $categoriesJson, ')
..write('organizerJson: $organizerJson, ')
..write('creatorJson: $creatorJson, ')
..write('colorId: $colorId, ')
@@ -19159,6 +19215,7 @@ typedef $$CalendarEventsTableCreateCompanionBuilder =
Value recurrenceJson,
Value remindersJson,
Value attendeesJson,
+ Value categoriesJson,
Value organizerJson,
Value creatorJson,
Value colorId,
@@ -19205,6 +19262,7 @@ typedef $$CalendarEventsTableUpdateCompanionBuilder =
Value recurrenceJson,
Value remindersJson,
Value attendeesJson,
+ Value categoriesJson,
Value organizerJson,
Value creatorJson,
Value colorId,
@@ -19453,6 +19511,11 @@ class $$CalendarEventsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
+ ColumnFilters get categoriesJson => $composableBuilder(
+ column: $table.categoriesJson,
+ builder: (column) => ColumnFilters(column),
+ );
+
ColumnFilters get organizerJson => $composableBuilder(
column: $table.organizerJson,
builder: (column) => ColumnFilters(column),
@@ -19761,6 +19824,11 @@ class $$CalendarEventsTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
+ ColumnOrderings get categoriesJson => $composableBuilder(
+ column: $table.categoriesJson,
+ builder: (column) => ColumnOrderings(column),
+ );
+
ColumnOrderings get organizerJson => $composableBuilder(
column: $table.organizerJson,
builder: (column) => ColumnOrderings(column),
@@ -20001,6 +20069,11 @@ class $$CalendarEventsTableAnnotationComposer
builder: (column) => column,
);
+ GeneratedColumn get categoriesJson => $composableBuilder(
+ column: $table.categoriesJson,
+ builder: (column) => column,
+ );
+
GeneratedColumn get organizerJson => $composableBuilder(
column: $table.organizerJson,
builder: (column) => column,
@@ -20241,6 +20314,7 @@ class $$CalendarEventsTableTableManager
Value recurrenceJson = const Value.absent(),
Value remindersJson = const Value.absent(),
Value attendeesJson = const Value.absent(),
+ Value categoriesJson = const Value.absent(),
Value organizerJson = const Value.absent(),
Value creatorJson = const Value.absent(),
Value colorId = const Value.absent(),
@@ -20285,6 +20359,7 @@ class $$CalendarEventsTableTableManager
recurrenceJson: recurrenceJson,
remindersJson: remindersJson,
attendeesJson: attendeesJson,
+ categoriesJson: categoriesJson,
organizerJson: organizerJson,
creatorJson: creatorJson,
colorId: colorId,
@@ -20331,6 +20406,7 @@ class $$CalendarEventsTableTableManager
Value recurrenceJson = const Value.absent(),
Value remindersJson = const Value.absent(),
Value attendeesJson = const Value.absent(),
+ Value categoriesJson = const Value.absent(),
Value organizerJson = const Value.absent(),
Value creatorJson = const Value.absent(),
Value colorId = const Value.absent(),
@@ -20375,6 +20451,7 @@ class $$CalendarEventsTableTableManager
recurrenceJson: recurrenceJson,
remindersJson: remindersJson,
attendeesJson: attendeesJson,
+ categoriesJson: categoriesJson,
organizerJson: organizerJson,
creatorJson: creatorJson,
colorId: colorId,
diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart
index 15026a6..d60353a 100644
--- a/lib/src/db/migrations.dart
+++ b/lib/src/db/migrations.dart
@@ -2,7 +2,7 @@ import 'package:drift/drift.dart';
import 'app_database.dart';
-const latestSchemaVersion = 4;
+const latestSchemaVersion = 5;
MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) {
return MigrationStrategy(
@@ -23,6 +23,9 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) {
if (from < 4) {
await _addV4CalendarTables(migrator, database);
}
+ if (from >= 4 && from < 5) {
+ await _addV5CalendarEventCategories(migrator, database);
+ }
await _createIndexes(database);
},
beforeOpen: (details) async {
@@ -72,6 +75,18 @@ Future _addV4CalendarTables(
await migrator.createTable(database.notificationSchedule);
}
+Future _addV5CalendarEventCategories(
+ Migrator migrator,
+ AppDatabase database,
+) async {
+ if (await _hasTable(database, 'calendar_events')) {
+ await migrator.addColumn(
+ database.calendarEvents,
+ database.calendarEvents.categoriesJson,
+ );
+ }
+}
+
Future _addV3Columns(Migrator migrator, AppDatabase database) async {
if (await _hasTable(database, 'accounts')) {
await migrator.addColumn(database.accounts, database.accounts.provider);
diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart
index 211d324..da25a7a 100644
--- a/lib/src/db/tables.dart
+++ b/lib/src/db/tables.dart
@@ -197,6 +197,7 @@ class CalendarEvents extends Table {
TextColumn get recurrenceJson => text().nullable()();
TextColumn get remindersJson => text().nullable()();
TextColumn get attendeesJson => text().nullable()();
+ TextColumn get categoriesJson => text().nullable()();
TextColumn get organizerJson => text().nullable()();
TextColumn get creatorJson => text().nullable()();
TextColumn get colorId => text().nullable()();
diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart
index 843ca79..98afe03 100644
--- a/lib/src/features/calendar/data/calendar_repository.dart
+++ b/lib/src/features/calendar/data/calendar_repository.dart
@@ -66,12 +66,20 @@ class CalendarSourceEntity {
}
class CalendarRepository {
- CalendarRepository({required AppDatabase database, DateTime Function()? now})
- : _database = database,
- _now = now ?? DateTime.now;
+ CalendarRepository({
+ required AppDatabase database,
+ DateTime Function()? now,
+ String? localTimeZone,
+ Future Function()? onNotificationScheduleChanged,
+ }) : _database = database,
+ _now = now ?? DateTime.now,
+ _localTimeZone = localTimeZone,
+ _onNotificationScheduleChanged = onNotificationScheduleChanged;
final AppDatabase _database;
final DateTime Function() _now;
+ final String? _localTimeZone;
+ final Future Function()? _onNotificationScheduleChanged;
Stream> watchSourcesForAccounts(
List accountIds,
@@ -269,6 +277,7 @@ class CalendarRepository {
recurrenceJson: Value(_json(event.recurrenceJson)),
remindersJson: Value(_json(event.remindersJson)),
attendeesJson: Value(_json(event.attendeesJson)),
+ categoriesJson: Value(_json(event.categoriesJson)),
organizerJson: Value(_json(event.organizerJson)),
creatorJson: Value(_json(event.creatorJson)),
colorId: Value(event.colorId),
@@ -340,11 +349,16 @@ class CalendarRepository {
final now = _now().millisecondsSinceEpoch;
final provider = TaskProviderParsing.fromStorageValue(source.provider);
final localEventId = 'local:${const Uuid().v4()}';
- final startTimeZone = _effectiveStartTimeZone(draft, source.timeZone);
+ final startTimeZone = _effectiveStartTimeZone(
+ draft,
+ source.timeZone,
+ _localTimeZone,
+ );
final endTimeZone = _effectiveEndTimeZone(
draft,
source.timeZone,
startTimeZone,
+ _localTimeZone,
);
final id = eventId(
accountId: draft.accountId,
@@ -388,6 +402,7 @@ class CalendarRepository {
recurrenceJson: Value(_json(draft.recurrence)),
remindersJson: Value(_json(draft.reminders)),
attendeesJson: Value(_json(_attendeesJson(draft, provider))),
+ categoriesJson: Value(_json(_categoriesJson(draft, provider))),
colorId: Value(draft.colorId),
visibility: Value(draft.visibilityOrSensitivity),
transparencyOrShowAs: Value(draft.showAs),
@@ -419,9 +434,10 @@ class CalendarRepository {
),
);
});
- await NotificationScheduleService(
- database: _database,
- ).rebuildUpcomingEventNotifications(draft.accountId);
+ await _notificationScheduleService().rebuildUpcomingEventNotifications(
+ draft.accountId,
+ );
+ await _onNotificationScheduleChanged?.call();
}
Future updateLocalEvent(EventEditorDraft draft) async {
@@ -437,11 +453,16 @@ class CalendarRepository {
)..where((row) => row.id.equals(eventId))).getSingle();
final now = _now().millisecondsSinceEpoch;
final provider = TaskProviderParsing.fromStorageValue(source.provider);
- final startTimeZone = _effectiveStartTimeZone(draft, source.timeZone);
+ final startTimeZone = _effectiveStartTimeZone(
+ draft,
+ source.timeZone,
+ _localTimeZone,
+ );
final endTimeZone = _effectiveEndTimeZone(
draft,
source.timeZone,
startTimeZone,
+ _localTimeZone,
);
final requestJson = jsonEncode(
_eventRequest(
@@ -477,6 +498,7 @@ class CalendarRepository {
recurrenceJson: Value(_json(draft.recurrence)),
remindersJson: Value(_json(draft.reminders)),
attendeesJson: Value(_json(_attendeesJson(draft, provider))),
+ categoriesJson: Value(_json(_categoriesJson(draft, provider))),
colorId: Value(draft.colorId),
visibility: Value(draft.visibilityOrSensitivity),
transparencyOrShowAs: Value(draft.showAs),
@@ -506,9 +528,10 @@ class CalendarRepository {
),
);
});
- await NotificationScheduleService(
- database: _database,
- ).rebuildUpcomingEventNotifications(draft.accountId);
+ await _notificationScheduleService().rebuildUpcomingEventNotifications(
+ draft.accountId,
+ );
+ await _onNotificationScheduleChanged?.call();
}
Future deleteLocalEvent(String eventId) async {
@@ -547,12 +570,20 @@ class CalendarRepository {
),
);
});
- await NotificationScheduleService(
- database: _database,
- ).rebuildUpcomingEventNotifications(existing.accountId);
+ await _notificationScheduleService().rebuildUpcomingEventNotifications(
+ existing.accountId,
+ );
+ await _onNotificationScheduleChanged?.call();
return existing.accountId;
}
+ NotificationScheduleService _notificationScheduleService() {
+ return NotificationScheduleService(
+ database: _database,
+ nowUtc: () => _now().toUtc(),
+ );
+ }
+
Future markMissingEventsDeleted({
required String accountId,
required BusyProvider provider,
@@ -670,7 +701,7 @@ Map _eventRequest(
'remindersJson': draft.reminders,
'attendeesJson': _attendeesJson(draft, provider),
'colorId': draft.colorId,
- 'categoriesJson': draft.categories,
+ 'categoriesJson': _categoriesJson(draft, provider),
'visibility': provider == TaskProvider.google
? draft.visibilityOrSensitivity
: null,
@@ -689,27 +720,61 @@ Map _eventRequest(
String? _effectiveStartTimeZone(
EventEditorDraft draft,
String? sourceTimeZone,
+ String? localTimeZone,
) {
if (draft.allDay) {
return null;
}
- return _nonBlank(draft.startTimeZone) ?? _nonBlank(sourceTimeZone) ?? 'UTC';
+ return _effectiveTimedEventZone(
+ explicitTimeZone: draft.startTimeZone,
+ sourceTimeZone: sourceTimeZone,
+ localTimeZone: localTimeZone,
+ );
}
String? _effectiveEndTimeZone(
EventEditorDraft draft,
String? sourceTimeZone,
String? startTimeZone,
+ String? localTimeZone,
) {
if (draft.allDay) {
return null;
}
- return _nonBlank(draft.endTimeZone) ??
+ final explicit = _nonBlank(draft.endTimeZone);
+ if (explicit != null && !_isUtcTimeZone(explicit)) {
+ return explicit;
+ }
+ return _nonBlank(startTimeZone) ??
+ _nonBlank(localTimeZone) ??
+ explicit ??
+ _nonBlank(sourceTimeZone) ??
+ 'UTC';
+}
+
+String _effectiveTimedEventZone({
+ required String? explicitTimeZone,
+ required String? sourceTimeZone,
+ required String? localTimeZone,
+}) {
+ final explicit = _nonBlank(explicitTimeZone);
+ if (explicit != null && !_isUtcTimeZone(explicit)) {
+ return explicit;
+ }
+ return _nonBlank(localTimeZone) ??
+ explicit ??
_nonBlank(sourceTimeZone) ??
- startTimeZone ??
'UTC';
}
+bool _isUtcTimeZone(String value) {
+ final normalized = value.trim().toLowerCase();
+ return normalized == 'utc' ||
+ normalized == 'etc/utc' ||
+ normalized == 'gmt' ||
+ normalized == 'etc/gmt';
+}
+
String? _nonBlank(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
@@ -727,6 +792,13 @@ Object? _attendeesJson(EventEditorDraft draft, BusyProvider provider) {
];
}
+Object? _categoriesJson(EventEditorDraft draft, BusyProvider provider) {
+ if (provider != TaskProvider.microsoft) {
+ return null;
+ }
+ return draft.categories;
+}
+
String? _date(DateTime? value) {
if (value == null) {
return null;
diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart
index eedd213..5f7d987 100644
--- a/lib/src/features/calendar/presentation/event_editor.dart
+++ b/lib/src/features/calendar/presentation/event_editor.dart
@@ -3,10 +3,10 @@ import 'package:yaru/yaru.dart';
import '../../../app/busymax_design.dart';
import '../../../app/busymax_dialogs.dart';
-import '../../../app/busymax_yaru_theme.dart';
import '../../../calendar_providers/calendar_colors.dart';
import '../../../l10n/l10n.dart';
import '../../../platform/linux_header_bar_service.dart';
+import '../../../schedule/schedule_projection.dart';
import '../../../task_providers/task_provider.dart';
import '../../tasks/presentation/desktop_date_time_fields.dart';
import '../data/calendar_repository.dart';
@@ -19,6 +19,7 @@ Future showBusyMaxEventEditorDialog(
required List sources,
LinuxHeaderBarService? headerBarService,
bool allowDelete = true,
+ Map> categorySuggestionsByAccount = const {},
}) async {
return showBusyMaxModalEditorDialog(
context,
@@ -29,6 +30,7 @@ Future showBusyMaxEventEditorDialog(
return EventEditor(
initialDraft: initialDraft,
sources: sources,
+ categorySuggestionsByAccount: categorySuggestionsByAccount,
onCancel: () => Navigator.of(context).pop(),
onSave: (draft) =>
Navigator.of(context).pop(EventEditorDialogResult.save(draft)),
@@ -65,10 +67,12 @@ class EventEditor extends StatefulWidget {
required this.onCancel,
required this.onSave,
this.onDelete,
+ this.categorySuggestionsByAccount = const {},
});
final EventEditorDraft initialDraft;
final List sources;
+ final Map> categorySuggestionsByAccount;
final VoidCallback onCancel;
final ValueChanged onSave;
final ValueChanged? onDelete;
@@ -80,8 +84,10 @@ class EventEditor extends StatefulWidget {
class _EventEditorState extends State {
late EventEditorDraft _draft;
final _guestController = TextEditingController();
+ final _categoryController = TextEditingController();
String? _guestError;
var _addingGuest = false;
+ var _addingCategory = false;
@override
void initState() {
@@ -92,6 +98,7 @@ class _EventEditorState extends State {
@override
void dispose() {
_guestController.dispose();
+ _categoryController.dispose();
super.dispose();
}
@@ -111,212 +118,171 @@ class _EventEditorState extends State {
? l10n.newEvent
: l10n.editEvent;
final canSave = dirty && _draft.canSave;
- return Column(
- mainAxisSize: MainAxisSize.min,
+ return BusyMaxModalEditorScaffold(
+ title: title,
+ cancelLabel: l10n.cancel,
+ saveLabel: l10n.save,
+ onCancel: widget.onCancel,
+ onSave: canSave ? () => widget.onSave(_draft) : null,
children: [
- BusyMaxEditorHeader(
- title: title,
- cancelLabel: l10n.cancel,
- saveLabel: l10n.save,
- onCancel: widget.onCancel,
- onSave: canSave ? () => widget.onSave(_draft) : null,
- ),
- const SizedBox(height: BusyMaxSpacing.headerInset),
- Flexible(
- child: SingleChildScrollView(
- child: BusyMaxClamp(
- maxWidth: 640,
- margin: EdgeInsets.zero,
- padding: const EdgeInsets.symmetric(
- horizontal: BusyMaxSpacing.lg,
+ BusyMaxGroupedList(
+ filled: true,
+ children: [
+ YaruListTile.square(
+ hoverColor: busyMaxEditorRowHoverColor(context),
+ title: TextFormField(
+ initialValue: _draft.title,
+ autofocus: true,
+ decoration: _plainEventFieldDecoration(
+ context,
+ labelText: l10n.title,
+ ),
+ onChanged: (value) {
+ setState(() {
+ _draft = _draft.copyWith(title: value);
+ });
+ },
),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- BusyMaxGroupedList(
- filled: true,
- children: [
- YaruListTile.square(
- hoverColor: busyMaxEditorRowHoverColor(context),
- title: TextFormField(
- initialValue: _draft.title,
- autofocus: true,
- decoration: _plainEventFieldDecoration(
- context,
- labelText: l10n.title,
- ),
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(title: value);
- });
- },
- ),
- ),
- YaruListTile.square(
- hoverColor: busyMaxEditorRowHoverColor(context),
- title: TextFormField(
- initialValue: _draft.location,
- decoration: _plainEventFieldDecoration(
- context,
- labelText: l10n.location,
- ),
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(location: value);
- });
- },
- ),
- ),
- ],
- ),
- BusyMaxGroupedList(filled: true, children: [_calendarRow()]),
- BusyMaxGroupedList(
- filled: true,
- children: [
- _EventTimeModeRow(
- allDay: _draft.allDay,
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(allDay: value);
- });
- },
- ),
- ],
- ),
- BusyMaxGroupedList(
- filled: true,
- children: [
- DesktopDateValueRow(
- label: l10n.startDate,
- date: _dateString(_draft.start),
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(
- start: _withDate(_draft.start, value),
- );
- });
- },
- emptyLabel: l10n.noneValue,
- ),
- if (!_draft.allDay)
- DesktopTimeValueRow(
- label: l10n.startTime,
- time: _timeString(_draft.start),
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(
- start: _withTime(_draft.start, value),
- );
- });
- },
- emptyLabel: l10n.noneValue,
- ),
- ],
- ),
- BusyMaxGroupedList(
- filled: true,
- children: [
- DesktopDateValueRow(
- label: l10n.endDate,
- date: _dateString(_draft.end),
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(
- end: _withDate(_draft.end, value),
- );
- });
- },
- emptyLabel: l10n.noneValue,
- ),
- if (!_draft.allDay)
- DesktopTimeValueRow(
- label: l10n.endTime,
- time: _timeString(_draft.end),
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(
- end: _withTime(_draft.end, value),
- );
- });
- },
- emptyLabel: l10n.noneValue,
- ),
- ],
- ),
- BusyMaxGroupedList(
- filled: true,
- children: [_repeatRow(provider)],
- ),
- BusyMaxGroupedList(
- title: l10n.reminder,
- filled: true,
- children: _reminderRows(provider),
- ),
- BusyMaxGroupedList(
- title: l10n.guests,
- filled: true,
- children: _guestRows(),
- ),
- BusyMaxGroupedList(
- filled: true,
- children: [
- YaruListTile.square(
- hoverColor: busyMaxEditorRowHoverColor(context),
- title: EventDescriptionEditor(
- provider: provider,
- text: _draft.description,
- contentType: _draft.descriptionContentType,
- html: _draft.descriptionHtml,
- onChanged: (value) {
- setState(() {
- _draft = _draft.copyWith(
- description: value.text,
- descriptionContentType: value.contentType,
- descriptionHtml: value.html,
- );
- });
- },
- ),
- ),
- ],
- ),
- BusyMaxGroupedList(
- filled: true,
- children: [_availabilityRow(provider)],
- ),
- BusyMaxGroupedList(
- filled: true,
- children: [_visibilityRow(provider)],
- ),
- if (_draft.eventId != null && widget.onDelete != null)
- const SizedBox(height: BusyMaxSpacing.md),
- if (_draft.eventId != null && widget.onDelete != null)
- BusyMaxGroupedList(
- filled: true,
- children: [
- BusyMaxActionRow(
- title: l10n.deleteEvent,
- titleWidget: Center(
- child: Text(
- l10n.deleteEvent,
- style: _eventEditorProminentActionStyle(
- context,
- color: Theme.of(context).colorScheme.error,
- fontWeight: FontWeight.w700,
- ),
- ),
- ),
- destructive: true,
- onTap: () => widget.onDelete?.call(_draft.eventId!),
- ),
- ],
- ),
- const SizedBox(height: BusyMaxSpacing.lg),
- ],
+ ),
+ YaruListTile.square(
+ hoverColor: busyMaxEditorRowHoverColor(context),
+ title: TextFormField(
+ initialValue: _draft.location,
+ decoration: _plainEventFieldDecoration(
+ context,
+ labelText: l10n.location,
+ ),
+ onChanged: (value) {
+ setState(() {
+ _draft = _draft.copyWith(location: value);
+ });
+ },
),
),
+ ],
+ ),
+ BusyMaxGroupedList(filled: true, children: [_calendarRow()]),
+ BusyMaxGroupedList(
+ filled: true,
+ children: [
+ BusyMaxTimeModeRow(allDay: _draft.allDay, onChanged: _setAllDay),
+ ],
+ ),
+ BusyMaxGroupedList(
+ filled: true,
+ children: [
+ DesktopDateValueRow(
+ label: l10n.startDate,
+ date: _dateString(_draft.start),
+ onChanged: (value) {
+ _setStart(_withDate(_draft.start, value));
+ },
+ emptyLabel: l10n.noneValue,
+ ),
+ if (!_draft.allDay)
+ DesktopTimeValueRow(
+ label: l10n.startTime,
+ time: _timeString(_draft.start),
+ onChanged: (value) {
+ _setStart(_withTime(_draft.start, value));
+ },
+ emptyLabel: '--:--',
+ allowEmpty: false,
+ ),
+ ],
+ ),
+ BusyMaxGroupedList(
+ filled: true,
+ children: [
+ DesktopDateValueRow(
+ label: l10n.endDate,
+ date: _dateString(_draft.end),
+ onChanged: (value) {
+ _setEnd(_withDate(_draft.end, value));
+ },
+ emptyLabel: l10n.noneValue,
+ ),
+ if (!_draft.allDay)
+ DesktopTimeValueRow(
+ label: l10n.endTime,
+ time: _timeString(_draft.end),
+ onChanged: (value) {
+ _setEnd(_withTime(_draft.end, value));
+ },
+ emptyLabel: '--:--',
+ allowEmpty: false,
+ ),
+ ],
+ ),
+ BusyMaxGroupedList(filled: true, children: [_repeatRow(provider)]),
+ BusyMaxGroupedList(
+ title: l10n.reminder,
+ filled: true,
+ children: _reminderRows(provider),
+ ),
+ BusyMaxGroupedList(
+ title: l10n.guests,
+ filled: true,
+ children: _guestRows(),
+ ),
+ if (provider == TaskProvider.microsoft)
+ BusyMaxGroupedList(
+ title: l10n.organizationSection,
+ filled: true,
+ children: [_categoriesRow()],
),
+ BusyMaxGroupedList(
+ filled: true,
+ children: [
+ YaruListTile.square(
+ hoverColor: busyMaxEditorRowHoverColor(context),
+ title: EventDescriptionEditor(
+ provider: provider,
+ text: _draft.description,
+ contentType: _draft.descriptionContentType,
+ html: _draft.descriptionHtml,
+ onChanged: (value) {
+ setState(() {
+ _draft = _draft.copyWith(
+ description: value.text,
+ descriptionContentType: value.contentType,
+ descriptionHtml: value.html,
+ );
+ });
+ },
+ ),
+ ),
+ ],
),
+ BusyMaxGroupedList(
+ filled: true,
+ children: [_availabilityRow(provider)],
+ ),
+ BusyMaxGroupedList(filled: true, children: [_visibilityRow(provider)]),
+ if (_draft.eventId != null && widget.onDelete != null)
+ const SizedBox(height: BusyMaxSpacing.md),
+ if (_draft.eventId != null && widget.onDelete != null)
+ BusyMaxGroupedList(
+ filled: true,
+ children: [
+ BusyMaxActionRow(
+ title: l10n.deleteEvent,
+ titleWidget: Center(
+ child: Text(
+ l10n.deleteEvent,
+ style: _eventEditorProminentActionStyle(
+ context,
+ color: Theme.of(context).colorScheme.error,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ destructive: true,
+ onTap: () => widget.onDelete?.call(_draft.eventId!),
+ ),
+ ],
+ ),
+ const SizedBox(height: BusyMaxSpacing.lg),
],
);
}
@@ -386,10 +352,17 @@ class _EventEditorState extends State {
onSelected: (value) {
final source = sources.firstWhere((source) => source.id == value);
setState(() {
+ if (source.provider != TaskProvider.microsoft) {
+ _addingCategory = false;
+ _categoryController.clear();
+ }
_draft = _draft.copyWith(
accountId: source.accountId,
sourceId: source.id,
providerCalendarId: source.providerCalendarId,
+ categories: source.provider == TaskProvider.microsoft
+ ? _draft.categories
+ : const [],
);
});
},
@@ -537,6 +510,34 @@ class _EventEditorState extends State {
return rows;
}
+ Widget _categoriesRow() {
+ final l10n = context.l10n;
+ return BusyMaxCategoryEditorRow(
+ title: l10n.categories,
+ addLabel: l10n.addCategory,
+ categories: _draft.categories,
+ suggestions:
+ widget.categorySuggestionsByAccount[_draft.accountId] ??
+ const [],
+ adding: _addingCategory,
+ controller: _categoryController,
+ inputKey: const Key('event-category-input'),
+ onAddPressed: () {
+ setState(() {
+ _addingCategory = true;
+ });
+ },
+ onSubmitted: _addCategory,
+ onCancelAdding: () {
+ _categoryController.clear();
+ setState(() {
+ _addingCategory = false;
+ });
+ },
+ onDeleted: _removeCategory,
+ );
+ }
+
Widget _availabilityRow(BusyProvider provider) {
final values = provider == TaskProvider.google
? const ['opaque', 'transparent']
@@ -612,88 +613,72 @@ class _EventEditorState extends State {
});
}
- void _setReminderMinutes(BusyProvider provider, List minutes) {
- final reminders = _remindersFor(provider, minutes);
+ void _addCategory(String value) {
+ final category = value.trim();
+ if (category.isEmpty || _draft.categories.contains(category)) {
+ return;
+ }
+ _categoryController.clear();
+ setState(() {
+ _addingCategory = false;
+ _draft = _draft.copyWith(categories: [..._draft.categories, category]);
+ });
+ }
+
+ void _removeCategory(String category) {
setState(() {
_draft = _draft.copyWith(
- reminders: reminders ?? _disabledRemindersFor(provider),
+ categories: [
+ for (final value in _draft.categories)
+ if (value != category) value,
+ ],
);
});
}
-}
-
-class _EventTimeModeRow extends StatelessWidget {
- const _EventTimeModeRow({required this.allDay, required this.onChanged});
- final bool allDay;
- final ValueChanged onChanged;
-
- @override
- Widget build(BuildContext context) {
- final l10n = context.l10n;
- return Padding(
- padding: const EdgeInsets.all(BusyMaxSpacing.xs),
- child: Row(
- children: [
- Expanded(
- child: _EventTimeModeButton(
- label: l10n.allDay,
- selected: allDay,
- onPressed: () => onChanged(true),
- ),
- ),
- const SizedBox(width: BusyMaxSpacing.xs),
- Expanded(
- child: _EventTimeModeButton(
- label: l10n.timeSlot,
- selected: !allDay,
- onPressed: () => onChanged(false),
- ),
- ),
- ],
- ),
- );
+ void _setAllDay(bool allDay) {
+ final start = _draft.start;
+ final end = _draft.end;
+ setState(() {
+ _draft = _draft.copyWith(
+ allDay: allDay,
+ end: start != null && (end == null || !end.isAfter(start))
+ ? _defaultEndFor(start, allDay)
+ : end,
+ );
+ });
}
-}
-class _EventTimeModeButton extends StatelessWidget {
- const _EventTimeModeButton({
- required this.label,
- required this.selected,
- required this.onPressed,
- });
+ void _setStart(DateTime start) {
+ final end = _draft.end;
+ setState(() {
+ _draft = _draft.copyWith(
+ start: start,
+ end: end == null || !end.isAfter(start)
+ ? _defaultEndFor(start, _draft.allDay)
+ : end,
+ );
+ });
+ }
- final String label;
- final bool selected;
- final VoidCallback onPressed;
+ void _setEnd(DateTime end) {
+ final start = _draft.start;
+ setState(() {
+ _draft = _draft.copyWith(
+ end: start != null && !end.isAfter(start)
+ ? _defaultEndFor(start, _draft.allDay)
+ : end,
+ );
+ });
+ }
- @override
- Widget build(BuildContext context) {
- final colorScheme = Theme.of(context).colorScheme;
- final surfaceColors = BusyMaxSurfaceColors.of(context);
- final borderRadius = BorderRadius.circular(BusyMaxRadius.headerButton);
- return Material(
- color: selected ? surfaceColors.controlHover : Colors.transparent,
- borderRadius: borderRadius,
- child: InkWell(
- borderRadius: borderRadius,
- onTap: selected ? null : onPressed,
- child: SizedBox(
- height: BusyMaxSizes.pushButtonHeight,
- child: Center(
- child: Text(
- label,
- style: _eventEditorProminentActionStyle(
- context,
- color: selected
- ? colorScheme.onSurface
- : colorScheme.onSurfaceVariant,
- ),
- ),
- ),
- ),
- ),
- );
+ void _setReminderMinutes(BusyProvider provider, List minutes) {
+ final reminders = _remindersFor(provider, minutes);
+ setState(() {
+ _draft = _draft.copyWith(
+ reminders: reminders ?? _disabledRemindersFor(provider),
+ );
+ });
}
}
@@ -768,6 +753,10 @@ DateTime _withTime(DateTime? current, String? time) {
return DateTime(date.year, date.month, date.day, parsed.hour, parsed.minute);
}
+DateTime _defaultEndFor(DateTime start, bool allDay) {
+ return start.add(allDay ? const Duration(days: 1) : const Duration(hours: 1));
+}
+
String _recurrenceType(Object? recurrence) {
if (recurrence is List && recurrence.isNotEmpty) {
final value = recurrence.first.toString().toUpperCase();
@@ -1003,7 +992,10 @@ Color _calendarSourceColor(BuildContext context, CalendarSourceEntity source) {
colorId: source.colorId,
),
) ??
- Theme.of(context).colorScheme.primary;
+ ScheduleProjection.deterministicSourceColor(
+ source.id,
+ Theme.of(context).colorScheme.brightness,
+ );
}
Color? _colorFromHex(String? value) {
diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart
index a94a478..94a9e2b 100644
--- a/lib/src/features/calendar/presentation/event_editor_draft.dart
+++ b/lib/src/features/calendar/presentation/event_editor_draft.dart
@@ -187,7 +187,11 @@ class EventEditorDraft {
final bool? hideAttendees;
final bool? allowNewTimeProposals;
- bool get canSave => title.trim().isNotEmpty;
+ bool get canSave =>
+ title.trim().isNotEmpty &&
+ start != null &&
+ end != null &&
+ end!.isAfter(start!);
EventEditorDraft copyWith({
String? accountId,
diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart
index 3bff833..ce11d7a 100644
--- a/lib/src/features/notifications/desktop_notification_service.dart
+++ b/lib/src/features/notifications/desktop_notification_service.dart
@@ -6,11 +6,15 @@ import 'package:desktop_notifications/desktop_notifications.dart';
import '../../app/app_settings.dart';
import '../../core/logging/redacting_logger.dart';
+typedef DesktopNotificationActionHandler = Future Function(String action);
+
abstract class DesktopNotificationBackend {
Future notify(
String summary, {
String body = '',
List hints = const [],
+ List actions = const [],
+ DesktopNotificationActionHandler? onAction,
});
Future close();
@@ -27,14 +31,24 @@ class FreedesktopNotificationBackend implements DesktopNotificationBackend {
String summary, {
String body = '',
List hints = const [],
- }) {
- return _client.notify(
+ List actions = const [],
+ DesktopNotificationActionHandler? onAction,
+ }) async {
+ final notification = await _client.notify(
summary,
appName: 'BusyMax',
appIcon: 'io.busystack.busymax',
body: body,
hints: hints,
+ actions: actions,
);
+ if (onAction != null) {
+ unawaited(
+ notification.action
+ .then(onAction)
+ .catchError((Object _) => Future.value()),
+ );
+ }
}
@override
@@ -113,48 +127,75 @@ class DesktopNotificationService {
);
}
- Future notifyEventReminder(String title, String? body) async {
+ Future notifyEventReminder(
+ String title,
+ String? body, {
+ Future Function()? onActivated,
+ }) async {
if (!_settings.notifyEventReminders || _isQuietHours()) {
return;
}
- final private =
- _settings.notificationDetailLevel == NotificationDetailLevel.private;
+ final private = _usesPrivateReminderText;
await _safeNotify(
private ? _strings.eventReminderTitle : redactForLog(title),
private
? _strings.detailsHidden
: _nonEmpty(redactForLog(body ?? ''), _strings.eventReminderBody),
NotificationCategory.device(),
+ onActivated: onActivated,
+ transient: false,
);
}
- Future notifyTaskReminder(String title, String? body) async {
+ Future notifyTaskReminder(
+ String title,
+ String? body, {
+ Future Function()? onActivated,
+ }) async {
if (!_settings.notifyTaskReminders || _isQuietHours()) {
return;
}
- final private =
- _settings.notificationDetailLevel == NotificationDetailLevel.private;
+ final private = _usesPrivateReminderText;
await _safeNotify(
private ? _strings.taskReminderTitle : redactForLog(title),
private
? _strings.detailsHidden
: _nonEmpty(redactForLog(body ?? ''), _strings.taskReminderBody),
NotificationCategory.device(),
+ onActivated: onActivated,
+ transient: false,
);
}
+ bool get _usesPrivateReminderText {
+ return !_settings.detailedNotifications &&
+ _settings.notificationDetailLevel == NotificationDetailLevel.private;
+ }
+
Future _safeNotify(
String summary,
String body,
- NotificationCategory category,
- ) async {
+ NotificationCategory category, {
+ Future Function()? onActivated,
+ bool transient = true,
+ }) async {
try {
await _backend.notify(
summary,
body: body,
+ actions: onActivated == null
+ ? const []
+ : [NotificationAction('default', _strings.openAction)],
+ onAction: onActivated == null
+ ? null
+ : (action) async {
+ if (action == 'default') {
+ await onActivated();
+ }
+ },
hints: [
NotificationHint.category(category),
- NotificationHint.transient(),
+ if (transient) NotificationHint.transient(),
],
);
} on Object {
@@ -213,6 +254,7 @@ class NotificationStrings {
required this.detailsHidden,
required this.eventReminderBody,
required this.taskReminderBody,
+ required this.openAction,
required this.syncFailureBody,
required this.conflictBody,
required this.dueTodayBody,
@@ -236,6 +278,7 @@ class NotificationStrings {
detailsHidden: 'Details are hidden by privacy settings.',
eventReminderBody: 'Event starts soon.',
taskReminderBody: 'Task is due soon.',
+ openAction: 'Open',
syncFailureBody: (message) => 'Background sync failed. $message',
conflictBody: (summary) => 'A pending local change was blocked. $summary',
dueTodayBody: (count) =>
@@ -252,6 +295,7 @@ class NotificationStrings {
'Details werden durch Datenschutzeinstellungen ausgeblendet.',
eventReminderBody: 'Der Termin beginnt bald.',
taskReminderBody: 'Die Aufgabe ist bald fällig.',
+ openAction: 'Öffnen',
syncFailureBody: (message) =>
'Hintergrundsynchronisierung fehlgeschlagen. $message',
conflictBody: (summary) =>
@@ -271,6 +315,7 @@ class NotificationStrings {
'Les détails sont masqués par les paramètres de confidentialité.',
eventReminderBody: 'L’événement commence bientôt.',
taskReminderBody: 'La tâche arrive bientôt à échéance.',
+ openAction: 'Ouvrir',
syncFailureBody: (message) =>
'La synchronisation en arrière-plan a échoué. $message',
conflictBody: (summary) =>
@@ -290,6 +335,7 @@ class NotificationStrings {
'Los detalles están ocultos por la configuración de privacidad.',
eventReminderBody: 'El evento empieza pronto.',
taskReminderBody: 'La tarea vence pronto.',
+ openAction: 'Abrir',
syncFailureBody: (message) =>
'Falló la sincronización en segundo plano. $message',
conflictBody: (summary) => 'Se bloqueó un cambio local pendiente. $summary',
@@ -305,6 +351,7 @@ class NotificationStrings {
final String detailsHidden;
final String eventReminderBody;
final String taskReminderBody;
+ final String openAction;
final String Function(String message) syncFailureBody;
final String Function(String summary) conflictBody;
final String Function(int count) dueTodayBody;
diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart
index f84e844..0e85446 100644
--- a/lib/src/features/notifications/notification_schedule_service.dart
+++ b/lib/src/features/notifications/notification_schedule_service.dart
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:drift/drift.dart';
+import '../../core/time/provider_date_time.dart';
import '../../db/app_database.dart';
import '../../task_providers/task_provider.dart';
@@ -23,6 +24,12 @@ class NotificationScheduleService {
.go();
final now = _nowUtc();
+ final sourcesById = {
+ for (final source in await (_database.select(
+ _database.calendarSources,
+ )..where((row) => row.accountId.equals(accountId))).get())
+ source.id: source,
+ };
final rows =
await (_database.select(_database.calendarEvents)..where(
(row) =>
@@ -36,12 +43,17 @@ class NotificationScheduleService {
if (start == null) {
continue;
}
- final reminders = _eventReminderMinutes(event);
+ final reminders = _eventReminderMinutes(
+ event,
+ source: sourcesById[event.calendarSourceId],
+ );
for (final minutes in reminders) {
- final scheduledAt = start.toUtc().subtract(Duration(minutes: minutes));
- if (scheduledAt.isBefore(now)) {
+ final startUtc = start.toUtc();
+ final reminderAt = startUtc.subtract(Duration(minutes: minutes));
+ if (startUtc.isBefore(now) || startUtc.isAtSameMomentAs(now)) {
continue;
}
+ final scheduledAt = reminderAt.isBefore(now) ? now : reminderAt;
await _upsertNotification(
id: 'event|${event.id}|$minutes',
accountId: accountId,
@@ -74,9 +86,10 @@ class NotificationScheduleService {
if (task.status == 'completed' || task.microsoftIsReminderOn != true) {
continue;
}
- final reminderAt = DateTime.tryParse(
- task.microsoftReminderDateTime ?? '',
- )?.toUtc();
+ final reminderAt = providerDateTimeAsUtcInstant(
+ task.microsoftReminderDateTime,
+ task.microsoftReminderTimeZone,
+ );
if (reminderAt == null || reminderAt.isBefore(now)) {
continue;
}
@@ -129,16 +142,16 @@ DateTime? _eventStart(CalendarEvent event) {
if (event.allDay) {
return _parseDate(event.startDate);
}
- return DateTime.tryParse(event.startDateTime ?? '');
+ return _parseDateTime(event.startDateTime, event.startTimeZone);
}
-List _eventReminderMinutes(CalendarEvent event) {
+List _eventReminderMinutes(CalendarEvent event, {CalendarSource? source}) {
final raw = event.remindersJson;
if (raw == null || raw.isEmpty) {
return const [];
}
final provider = TaskProviderParsing.fromStorageValue(event.provider);
- final decoded = jsonDecode(raw);
+ final decoded = _decodeJson(raw);
if (provider == TaskProvider.microsoft && decoded is Map) {
final map = decoded.cast();
final enabled = map['isReminderOn'] == true;
@@ -147,6 +160,9 @@ List _eventReminderMinutes(CalendarEvent event) {
}
if (provider == TaskProvider.google && decoded is Map) {
final map = decoded.cast();
+ if (map['useDefault'] == true) {
+ return _googleDefaultReminderMinutes(source);
+ }
final overrides = map['overrides'];
if (overrides is! List) {
return const [];
@@ -160,9 +176,66 @@ List _eventReminderMinutes(CalendarEvent event) {
return const [];
}
+Object? _decodeJson(String raw) {
+ try {
+ return jsonDecode(raw);
+ } on Object {
+ return null;
+ }
+}
+
+List _googleDefaultReminderMinutes(CalendarSource? source) {
+ final raw = source?.rawJson;
+ if (raw == null || raw.isEmpty) {
+ return const [];
+ }
+
+ final decoded = _decodeJson(raw);
+ if (decoded is! Map) {
+ return const [];
+ }
+
+ final reminders = decoded['defaultReminders'];
+ if (reminders is! List) {
+ return const [];
+ }
+
+ return [
+ for (final item in reminders)
+ if (item is Map && item['method'] == 'popup' && item['minutes'] is int)
+ item['minutes'] as int,
+ ];
+}
+
DateTime? _parseDate(String? value) {
if (value == null || value.length < 10) {
return null;
}
return DateTime.tryParse('${value.substring(0, 10)}T00:00:00');
}
+
+DateTime? _parseDateTime(String? value, String? timeZone) {
+ final parsed = DateTime.tryParse(value ?? '');
+ if (parsed == null || parsed.isUtc) {
+ return parsed;
+ }
+
+ final normalizedZone = timeZone?.trim().toLowerCase();
+ if (normalizedZone == 'utc' ||
+ normalizedZone == 'etc/utc' ||
+ normalizedZone == 'gmt' ||
+ normalizedZone == 'etc/gmt') {
+ return DateTime.utc(
+ parsed.year,
+ parsed.month,
+ parsed.day,
+ parsed.hour,
+ parsed.minute,
+ parsed.second,
+ parsed.millisecond,
+ parsed.microsecond,
+ );
+ }
+
+ return parsed;
+}
diff --git a/lib/src/features/notifications/notification_scheduler.dart b/lib/src/features/notifications/notification_scheduler.dart
index 96106ac..2910258 100644
--- a/lib/src/features/notifications/notification_scheduler.dart
+++ b/lib/src/features/notifications/notification_scheduler.dart
@@ -11,28 +11,67 @@ class NotificationScheduler {
required DesktopNotificationService notifications,
Duration interval = const Duration(minutes: 1),
DateTime Function()? nowUtc,
+ Future Function(NotificationScheduleData row)?
+ onNotificationActivated,
}) : _database = database,
_notifications = notifications,
_interval = interval,
- _nowUtc = nowUtc ?? (() => DateTime.now().toUtc());
+ _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()),
+ _onNotificationActivated = onNotificationActivated;
final AppDatabase _database;
final DesktopNotificationService _notifications;
final Duration _interval;
final DateTime Function() _nowUtc;
+ final Future Function(NotificationScheduleData row)?
+ _onNotificationActivated;
Timer? _timer;
+ Timer? _dueTimer;
+ StreamSubscription>? _scheduleSubscription;
+ var _checking = false;
+ var _checkAgain = false;
void start() {
_timer ??= Timer.periodic(_interval, (_) => unawaited(checkNow()));
+ _scheduleSubscription ??= _database
+ .select(_database.notificationSchedule)
+ .watch()
+ .listen((_) => unawaited(_handleScheduleChanged()));
unawaited(checkNow());
}
void stop() {
_timer?.cancel();
_timer = null;
+ _dueTimer?.cancel();
+ _dueTimer = null;
+ unawaited(_scheduleSubscription?.cancel());
+ _scheduleSubscription = null;
+ }
+
+ Future _handleScheduleChanged() async {
+ await checkNow();
}
Future checkNow() async {
+ if (_checking) {
+ _checkAgain = true;
+ return;
+ }
+
+ _checking = true;
+ try {
+ do {
+ _checkAgain = false;
+ await _checkDueNotifications();
+ } while (_checkAgain);
+ await _scheduleNextDueCheck();
+ } finally {
+ _checking = false;
+ }
+ }
+
+ Future _checkDueNotifications() async {
final now = _nowUtc().millisecondsSinceEpoch;
final rows =
await (_database.select(_database.notificationSchedule)..where(
@@ -44,9 +83,21 @@ class NotificationScheduler {
.get();
for (final row in rows) {
if (row.sourceType == 'event') {
- await _notifications.notifyEventReminder(row.title, row.body);
+ await _notifications.notifyEventReminder(
+ row.title,
+ row.body,
+ onActivated: _onNotificationActivated == null
+ ? null
+ : () => _onNotificationActivated(row),
+ );
} else if (row.sourceType == 'task') {
- await _notifications.notifyTaskReminder(row.title, row.body);
+ await _notifications.notifyTaskReminder(
+ row.title,
+ row.body,
+ onActivated: _onNotificationActivated == null
+ ? null
+ : () => _onNotificationActivated(row),
+ );
}
await (_database.update(
_database.notificationSchedule,
@@ -58,4 +109,27 @@ class NotificationScheduler {
);
}
}
+
+ Future _scheduleNextDueCheck() async {
+ _dueTimer?.cancel();
+ _dueTimer = null;
+
+ final next =
+ await (_database.select(_database.notificationSchedule)
+ ..where(
+ (row) => row.sentAtUtc.isNull() & row.dismissedAtUtc.isNull(),
+ )
+ ..orderBy([(row) => OrderingTerm.asc(row.scheduledAtUtc)])
+ ..limit(1))
+ .getSingleOrNull();
+ if (next == null) {
+ return;
+ }
+
+ final now = _nowUtc().millisecondsSinceEpoch;
+ final delay = Duration(
+ milliseconds: (next.scheduledAtUtc - now).clamp(0, 2147483647),
+ );
+ _dueTimer = Timer(delay, () => unawaited(checkNow()));
+ }
}
diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart
new file mode 100644
index 0000000..ff10188
--- /dev/null
+++ b/lib/src/features/schedule/application/compact_agenda_controller.dart
@@ -0,0 +1,108 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../app/app_bootstrap.dart';
+import '../../../platform/main_window_command_client.dart';
+import '../../../schedule/schedule_item.dart';
+import '../../calendar/data/calendar_repository.dart';
+import '../../calendar/presentation/event_editor_draft.dart';
+import '../../tasks/data/tasks_repository.dart';
+import 'compact_agenda_data.dart';
+
+final compactAgendaControllerProvider = Provider((
+ ref,
+) {
+ return CompactAgendaController(ref);
+});
+
+class CompactAgendaController {
+ const CompactAgendaController(this._ref);
+
+ final Ref _ref;
+
+ Future createTask({
+ required String accountId,
+ required String taskListId,
+ required TaskCreateInput input,
+ }) async {
+ final repository = TasksRepository(
+ database: _ref.read(databaseProvider),
+ accountId: accountId,
+ );
+ await repository.createTask(taskListId, input);
+
+ await _requestTaskSync(accountId);
+
+ _ref.invalidate(compactAgendaDataProvider);
+ _ref.invalidate(compactAgendaDataForQueryProvider);
+ }
+
+ Future saveEvent(EventEditorDraft draft) async {
+ final repository = CalendarRepository(
+ database: _ref.read(databaseProvider),
+ localTimeZone: _ref.read(localTimeZoneProvider),
+ );
+ await repository.updateLocalEvent(draft);
+ await _requestCalendarSync(draft.accountId);
+
+ _ref.invalidate(compactAgendaDataProvider);
+ _ref.invalidate(compactAgendaDataForQueryProvider);
+ }
+
+ Future deleteEvent(String eventId) async {
+ final repository = CalendarRepository(
+ database: _ref.read(databaseProvider),
+ localTimeZone: _ref.read(localTimeZoneProvider),
+ );
+ final accountId = await repository.deleteLocalEvent(eventId);
+ await _requestCalendarSync(accountId);
+
+ _ref.invalidate(compactAgendaDataProvider);
+ _ref.invalidate(compactAgendaDataForQueryProvider);
+ }
+
+ Future taskMutated(String accountId) async {
+ await _requestTaskSync(accountId);
+ _ref.invalidate(compactAgendaDataProvider);
+ _ref.invalidate(compactAgendaDataForQueryProvider);
+ }
+
+ Future setTaskCompleted(TaskScheduleItem item, bool completed) async {
+ final fields = {
+ 'status': completed ? 'completed' : 'needsAction',
+ 'completed': completed ? DateTime.now().toUtc().toIso8601String() : null,
+ };
+
+ final repository = TasksRepository(
+ database: _ref.read(databaseProvider),
+ accountId: item.accountId,
+ );
+ await repository.patchTask(item.sourceId, item.id, TaskPatchInput(fields));
+
+ await _requestTaskSync(item.accountId);
+
+ _ref.invalidate(compactAgendaDataProvider);
+ _ref.invalidate(compactAgendaDataForQueryProvider);
+ }
+
+ Future _requestTaskSync(String accountId) async {
+ try {
+ await const MainWindowCommandClient().requestTaskSync(accountId);
+ } on Object {
+ // The pending operation remains queued and will sync when the main engine
+ // is available.
+ }
+ }
+
+ Future _requestCalendarSync(String accountId) async {
+ try {
+ await const MainWindowCommandClient().requestCalendarSync(accountId);
+ } on Object {
+ // The pending operation remains queued and will sync when the main engine
+ // is available.
+ }
+ }
+}
+
+String compactAgendaTaskMutationKey(TaskScheduleItem item) {
+ return '${item.accountId}:${item.sourceId}:${item.id}';
+}
diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart
new file mode 100644
index 0000000..994d183
--- /dev/null
+++ b/lib/src/features/schedule/application/compact_agenda_data.dart
@@ -0,0 +1,204 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../app/app_bootstrap.dart';
+import '../../../schedule/schedule_filters.dart';
+import '../../../schedule/schedule_item.dart';
+import '../../../schedule/schedule_range.dart';
+import '../../../schedule/schedule_sorting.dart';
+import '../../../schedule/schedule_source_visibility.dart';
+import '../../task_lists/data/task_lists_repository.dart';
+import 'compact_agenda_sections.dart';
+
+const compactAgendaInitialDays = 30;
+const compactAgendaPageDays = 30;
+
+final compactAgendaDataProvider = FutureProvider.autoDispose(
+ (ref) {
+ return ref.watch(
+ compactAgendaDataForQueryProvider(CompactAgendaQuery.initial).future,
+ );
+ },
+);
+
+final compactAgendaDataForQueryProvider = FutureProvider.autoDispose
+ .family((ref, query) {
+ return _loadCompactAgendaData(ref, query);
+ });
+
+Future _loadCompactAgendaData(
+ Ref ref,
+ CompactAgendaQuery query,
+) async {
+ final now = DateTime.now();
+ final today = DateTime(now.year, now.month, now.day);
+ final days = query.futureDays < 1
+ ? compactAgendaInitialDays
+ : query.futureDays;
+ final end = today.add(Duration(days: days));
+ final range = ScheduleRange(start: today, end: end);
+
+ CompactAgendaData empty({
+ required bool hasSignedInAccounts,
+ required bool hasSources,
+ }) {
+ return CompactAgendaData(
+ today: today,
+ range: range,
+ items: const [],
+ hasMoreOverdueTasks: false,
+ hasMoreNoDateTasks: false,
+ hasSignedInAccounts: hasSignedInAccounts,
+ hasSources: hasSources,
+ generatedAt: now,
+ );
+ }
+
+ final accounts = await ref
+ .read(accountsRepositoryProvider)
+ .listSignedInAccounts();
+ if (accounts.isEmpty) {
+ return empty(hasSignedInAccounts: false, hasSources: false);
+ }
+
+ final accountIds = accounts.map((account) => account.id).toSet();
+ final calendarSources = await ref
+ .read(calendarRepositoryProvider)
+ .listVisibleSources(accountIds.toList());
+ final taskLists = [];
+ for (final account in accounts) {
+ taskLists.addAll(
+ await ref
+ .read(taskListsRepositoryForAccountProvider(account.id))
+ .listTaskLists(),
+ );
+ }
+
+ final visibility = ScheduleSourceVisibility.fromSources(
+ calendarSources: calendarSources,
+ taskLists: taskLists,
+ settings: ref.read(appSettingsControllerProvider),
+ );
+ final hasSources =
+ visibility.visibleCalendarSourceIds.isNotEmpty ||
+ visibility.visibleTaskListIds.isNotEmpty;
+ if (!hasSources) {
+ return empty(hasSignedInAccounts: true, hasSources: false);
+ }
+
+ final repository = ref.read(scheduleRepositoryProvider);
+ final datedItemsFuture = repository.listItems(
+ range: range,
+ filters: ScheduleFilters(
+ accountIds: accountIds,
+ sourceIds: visibility.visibleCalendarSourceIds,
+ taskListIds: visibility.visibleTaskListIds,
+ sourceFilterActive: true,
+ taskListFilterActive: true,
+ includeCalendarEvents: true,
+ includeTasks: true,
+ showCompletedTasks: false,
+ showNoDateTasks: false,
+ ),
+ );
+ final overdueTasksFuture = repository.listOverdueTasks(
+ before: today,
+ limit: query.overdueLimit,
+ filters: ScheduleFilters(
+ accountIds: accountIds,
+ taskListIds: visibility.visibleTaskListIds,
+ taskListFilterActive: true,
+ includeTasks: true,
+ showCompletedTasks: false,
+ ),
+ );
+ final noDateTasksFuture = repository.listNoDateTasks(
+ limit: query.noDateLimit,
+ filters: ScheduleFilters(
+ accountIds: accountIds,
+ taskListIds: visibility.visibleTaskListIds,
+ taskListFilterActive: true,
+ includeTasks: true,
+ showCompletedTasks: false,
+ ),
+ );
+ final datedItems = await datedItemsFuture;
+ final overdueTasks = await overdueTasksFuture;
+ final noDateTasks = await noDateTasksFuture;
+ final rawItems = [...datedItems, ...overdueTasks.items, ...noDateTasks.items];
+
+ final items = rawItems.where((item) {
+ final start = item.start;
+ if (start == null) {
+ return item is TaskScheduleItem && !item.completed;
+ }
+ if (item is CalendarScheduleItem) {
+ return !start.isBefore(today) && start.isBefore(end);
+ }
+ if (item is TaskScheduleItem) {
+ return !item.completed && start.isBefore(end);
+ }
+ return false;
+ }).toList()..sort(compareScheduleItems);
+
+ return CompactAgendaData(
+ today: today,
+ range: range,
+ items: items,
+ hasMoreOverdueTasks: overdueTasks.hasMore,
+ hasMoreNoDateTasks: noDateTasks.hasMore,
+ hasSignedInAccounts: true,
+ hasSources: true,
+ generatedAt: now,
+ );
+}
+
+class CompactAgendaData {
+ const CompactAgendaData({
+ required this.today,
+ required this.range,
+ required this.items,
+ required this.hasMoreOverdueTasks,
+ required this.hasMoreNoDateTasks,
+ required this.hasSignedInAccounts,
+ required this.hasSources,
+ required this.generatedAt,
+ });
+
+ final DateTime today;
+ final ScheduleRange range;
+ final List items;
+ final bool hasMoreOverdueTasks;
+ final bool hasMoreNoDateTasks;
+ final bool hasSignedInAccounts;
+ final bool hasSources;
+ final DateTime generatedAt;
+}
+
+class CompactAgendaQuery {
+ const CompactAgendaQuery({
+ required this.futureDays,
+ required this.overdueLimit,
+ required this.noDateLimit,
+ });
+
+ static const initial = CompactAgendaQuery(
+ futureDays: compactAgendaInitialDays,
+ overdueLimit: compactAgendaInitialOverdueLimit,
+ noDateLimit: compactAgendaInitialNoDateLimit,
+ );
+
+ final int futureDays;
+ final int overdueLimit;
+ final int noDateLimit;
+
+ @override
+ bool operator ==(Object other) {
+ return other is CompactAgendaQuery &&
+ other.futureDays == futureDays &&
+ other.overdueLimit == overdueLimit &&
+ other.noDateLimit == noDateLimit;
+ }
+
+ @override
+ int get hashCode => Object.hash(futureDays, overdueLimit, noDateLimit);
+}
diff --git a/lib/src/features/schedule/application/compact_agenda_sections.dart b/lib/src/features/schedule/application/compact_agenda_sections.dart
new file mode 100644
index 0000000..ead417d
--- /dev/null
+++ b/lib/src/features/schedule/application/compact_agenda_sections.dart
@@ -0,0 +1,105 @@
+import '../../../schedule/schedule_item.dart';
+import '../../../schedule/schedule_projection.dart';
+import '../../../schedule/schedule_sorting.dart';
+
+const compactAgendaInitialOverdueLimit = 8;
+const compactAgendaOverduePageSize = 8;
+const compactAgendaInitialNoDateLimit = 8;
+const compactAgendaNoDatePageSize = 8;
+
+enum CompactAgendaSectionKind { overdue, day, noDate }
+
+class CompactAgendaSection {
+ const CompactAgendaSection({
+ required this.kind,
+ required this.items,
+ this.day,
+ this.hasMore = false,
+ });
+
+ final CompactAgendaSectionKind kind;
+ final DateTime? day;
+ final List items;
+ final bool hasMore;
+}
+
+List buildCompactAgendaSections({
+ required DateTime today,
+ required List