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`. + +

+ BusyMax month view +

+ +

+ 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 + + + + + + + + + + +
+ BusyMax week view +
+ Week view with color-coded calendars and scheduled tasks. +
+ BusyMax day view +
+ Day view for focused daily planning. +
+ BusyMax agenda view +
+ Agenda view with upcoming events, tasks, and details. +
+ BusyMax new task editor +
+ Task creation with lists, due dates, reminders, and repeat options. +
+ +
+More screenshots + +
+ +

+ BusyMax year view +

+ +

+ BusyMax event editor +

+ +

+ BusyMax compact agenda window + BusyMax compact agenda event details + BusyMax compact agenda event editor +

+ +
## 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 items, + DateTime? end, + int overdueLimit = compactAgendaInitialOverdueLimit, + int noDateLimit = compactAgendaInitialNoDateLimit, + bool hasMoreOverdueTasks = false, + bool hasMoreNoDateTasks = false, +}) { + final rangeEnd = end == null ? null : ScheduleProjection.day(end); + final visibleOverdueLimit = overdueLimit < 1 + ? compactAgendaInitialOverdueLimit + : overdueLimit; + final visibleNoDateLimit = noDateLimit < 1 + ? compactAgendaInitialNoDateLimit + : noDateLimit; + final overdueTasks = items.whereType().where((item) { + final start = item.start; + return start != null && + !item.completed && + ScheduleProjection.day(start).isBefore(today); + }).toList()..sort(compareScheduleItems); + + final sections = []; + if (overdueTasks.isNotEmpty) { + sections.add( + CompactAgendaSection( + kind: CompactAgendaSectionKind.overdue, + items: overdueTasks.take(visibleOverdueLimit).toList(), + hasMore: + hasMoreOverdueTasks || overdueTasks.length > visibleOverdueLimit, + ), + ); + } + + final grouped = >{}; + final noDateTasks = []; + for (final item in items) { + if (item is TaskScheduleItem && item.completed) { + continue; + } + final start = item.start; + if (start == null) { + if (item is TaskScheduleItem) { + noDateTasks.add(item); + } + continue; + } + final day = ScheduleProjection.day(start); + if (day.isBefore(today) || (rangeEnd != null && !day.isBefore(rangeEnd))) { + continue; + } + grouped.putIfAbsent(day, () => []).add(item); + } + + final days = grouped.keys.toList()..sort(); + if (noDateTasks.isNotEmpty) { + noDateTasks.sort(compareScheduleItems); + sections.add( + CompactAgendaSection( + kind: CompactAgendaSectionKind.noDate, + items: noDateTasks.take(visibleNoDateLimit).toList(), + hasMore: hasMoreNoDateTasks || noDateTasks.length > visibleNoDateLimit, + ), + ); + } + + for (final day in days) { + final dayItems = grouped[day]!..sort(compareScheduleItems); + sections.add( + CompactAgendaSection( + kind: CompactAgendaSectionKind.day, + day: day, + items: dayItems, + ), + ); + } + + return sections; +} diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart new file mode 100644 index 0000000..70064cf --- /dev/null +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:system_theme/system_theme.dart'; +import 'package:ubuntu_localizations/ubuntu_localizations.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/app_theme.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/system_accent.dart'; +import '../../../platform/gtk_font_service.dart'; +import '../../../platform/busymax_window_args.dart'; +import '../application/compact_agenda_data.dart'; +import 'compact_agenda_panel.dart'; + +const _compactAgendaPanelWidth = 420.0; +const _compactAgendaPanelHeight = 680.0; +const _compactAgendaWindowSize = Size( + _compactAgendaPanelWidth + BusyMaxShadow.windowMargin * 2, + _compactAgendaPanelHeight + BusyMaxShadow.windowMargin * 2, +); +const _compactAgendaWindowChannel = MethodChannel( + 'io.busystack.busymax/compact_agenda_window', +); + +class BusyMaxCompactAgendaApp extends ConsumerStatefulWidget { + const BusyMaxCompactAgendaApp({ + required this.windowController, + required this.windowArgs, + super.key, + }); + + final WindowController windowController; + final BusyMaxWindowArgs windowArgs; + + @override + ConsumerState createState() => + _BusyMaxCompactAgendaAppState(); +} + +class _BusyMaxCompactAgendaAppState + extends ConsumerState + with WindowListener { + @override + void initState() { + super.initState(); + unawaited( + widget.windowController.setWindowMethodHandler(_handleWindowMethodCall), + ); + windowManager.addListener(this); + unawaited(windowManager.setPreventClose(true)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_show()); + } + }); + } + + @override + void dispose() { + unawaited(widget.windowController.setWindowMethodHandler(null)); + windowManager.removeListener(this); + super.dispose(); + } + + Future _handleWindowMethodCall(MethodCall call) async { + switch (call.method) { + case 'busymax.compactAgenda.show': + await _show(call.arguments); + return true; + case 'busymax.compactAgenda.hide': + await widget.windowController.hide(); + return true; + case 'busymax.compactAgenda.toggle': + final visible = await windowManager.isVisible(); + final focused = await _isFocused(); + if (visible && focused) { + await widget.windowController.hide(); + } else { + await _show(call.arguments); + } + return true; + case 'busymax.compactAgenda.refresh': + ref.invalidate(compactAgendaDataProvider); + ref.invalidate(compactAgendaDataForQueryProvider); + return true; + case 'busymax.compactAgenda.destroy': + await windowManager.setPreventClose(false); + await windowManager.destroy(); + return true; + } + + throw MissingPluginException('Not implemented: ${call.method}'); + } + + Future _show([Object? rawArgs]) async { + final position = _requestedPosition(rawArgs) ?? _initialRequestedPosition(); + final shownNatively = await _showNativeWindow(position); + if (!shownNatively) { + await _moveNearTrayArea(position); + await widget.windowController.show(); + unawaited(_focusNearTrayArea()); + } + ref.invalidate(compactAgendaDataProvider); + ref.invalidate(compactAgendaDataForQueryProvider); + } + + Future _showNativeWindow(Offset? position) async { + try { + final result = await _compactAgendaWindowChannel.invokeMethod( + 'show', + _nativeWindowArguments(position), + ); + return result ?? false; + } on MissingPluginException { + return false; + } on Object { + return false; + } + } + + Future _moveNearTrayArea(Offset? requestedPosition) async { + try { + final position = requestedPosition ?? _initialRequestedPosition(); + if (position == null) { + await windowManager.setSize(_compactAgendaWindowSize); + await windowManager.setAlignment(Alignment.topRight); + return; + } + await windowManager.setBounds( + null, + position: position, + size: _compactAgendaWindowSize, + ); + } on Object { + // Positioning is best-effort, especially on Wayland. + } + } + + Future _focusNearTrayArea() async { + try { + await windowManager.focus(); + } on Object { + // Positioning is best-effort, especially on Wayland. + } + } + + Offset? _initialRequestedPosition() { + final x = widget.windowArgs.requestedPositionX; + final y = widget.windowArgs.requestedPositionY; + if (x == null || y == null) { + return null; + } + return Offset(x, y); + } + + Offset? _requestedPosition(Object? rawArgs) { + if (rawArgs is! Map) { + return null; + } + final position = rawArgs['position']; + if (position is! Map) { + return null; + } + final x = position['x']; + final y = position['y']; + if (x is! num || y is! num) { + return null; + } + final dx = x.toDouble(); + final dy = y.toDouble(); + if (!dx.isFinite || !dy.isFinite) { + return null; + } + return Offset(dx, dy); + } + + Map _nativeWindowArguments(Offset? position) { + return { + if (position != null) ...{'x': position.dx, 'y': position.dy}, + 'width': _compactAgendaWindowSize.width, + 'height': _compactAgendaWindowSize.height, + }; + } + + Future _isFocused() async { + try { + return await windowManager.isFocused(); + } on Object { + return false; + } + } + + @override + void onWindowClose() { + unawaited(widget.windowController.hide()); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final ubuntuAccentColor = ref + .watch(ubuntuSystemAccentColorProvider) + .valueOrNull; + final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; + final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; + + return SystemThemeBuilder( + builder: (context, systemColor) { + final accentColor = ubuntuAccentColor ?? systemColor.accent; + return MaterialApp( + title: 'BusyMax Agenda', + debugShowCheckedModeBanner: false, + theme: buildBusyMaxTheme( + brightness: Brightness.light, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + ), + darkTheme: buildBusyMaxTheme( + brightness: Brightness.dark, + accentColor: accentColor, + family: settings.themeFamily, + gtkFontFamily: gtkFont?.family, + gtkFontSize: gtkFont?.size, + gtkThemeColors: gtkThemeColors, + ), + themeMode: settings.themeMode, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...GlobalUbuntuLocalizations.delegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + backgroundColor: Colors.transparent, + body: Padding( + padding: EdgeInsets.all(BusyMaxShadow.windowMargin), + child: CompactAgendaPanel(), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart new file mode 100644 index 0000000..6e7adb8 --- /dev/null +++ b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/l10n.dart'; +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_projection.dart'; + +String compactAgendaDayLabel( + BuildContext context, { + required DateTime today, + required DateTime day, +}) { + final normalizedToday = ScheduleProjection.day(today); + final normalizedDay = ScheduleProjection.day(day); + if (normalizedDay == normalizedToday) { + return context.l10n.today; + } + if (normalizedDay == normalizedToday.add(const Duration(days: 1))) { + return context.l10n.tomorrow; + } + final locale = Localizations.localeOf(context).toString(); + return DateFormat('EEE, MMM d', locale).format(normalizedDay); +} + +String compactAgendaTodaySubtitle(BuildContext context, DateTime today) { + final locale = Localizations.localeOf(context).toString(); + return '${context.l10n.today} - ${DateFormat.MMMd(locale).format(today)}'; +} + +String compactAgendaItemMeta( + BuildContext context, + ScheduleItem item, { + required DateTime today, +}) { + if (item is TaskScheduleItem) { + return _taskDueLabel(context, item, today: today); + } + return _eventTimeLabel(context, item); +} + +String _eventTimeLabel(BuildContext context, ScheduleItem item) { + if (item.allDay) { + return context.l10n.compactAgendaAllDay; + } + final start = item.start; + if (start == null) { + return ''; + } + final end = item.end; + final startText = _formatTime(context, start); + if (end == null || + !end.isAfter(start) || + ScheduleProjection.day(end) != ScheduleProjection.day(start)) { + return startText; + } + return '$startText-${_formatTime(context, end)}'; +} + +String _taskDueLabel( + BuildContext context, + TaskScheduleItem item, { + required DateTime today, +}) { + final start = item.start; + if (start == null) { + return ''; + } + final day = ScheduleProjection.day(start); + final normalizedToday = ScheduleProjection.day(today); + if (day == normalizedToday) { + return item.allDay + ? context.l10n.compactAgendaDueToday + : context.l10n.compactAgendaDueOn(_formatTime(context, start)); + } + if (day == normalizedToday.add(const Duration(days: 1))) { + return context.l10n.compactAgendaDueTomorrow; + } + if (day == normalizedToday.subtract(const Duration(days: 1))) { + return context.l10n.compactAgendaDueOn( + DateFormat.E(Localizations.localeOf(context).toString()).format(day), + ); + } + return context.l10n.compactAgendaDueOn( + DateFormat.MMMd(Localizations.localeOf(context).toString()).format(day), + ); +} + +String _formatTime(BuildContext context, DateTime value) { + return MaterialLocalizations.of(context).formatTimeOfDay( + TimeOfDay.fromDateTime(value), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); +} diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart new file mode 100644 index 0000000..b8ea2a4 --- /dev/null +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -0,0 +1,1468 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_yaru_theme.dart'; +import '../../../core/logging/redacting_logger.dart'; +import '../../../l10n/l10n.dart'; +import '../../../platform/main_window_command_client.dart'; +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_projection.dart'; +import '../../../task_providers/task_provider.dart'; +import '../../calendar/data/calendar_repository.dart'; +import '../../calendar/presentation/event_editor.dart'; +import '../../calendar/presentation/event_editor_draft.dart'; +import '../../task_lists/data/task_lists_repository.dart'; +import '../../tasks/data/tasks_repository.dart'; +import '../../tasks/presentation/new_task_dialog.dart'; +import '../../tasks/presentation/task_details_pane.dart'; +import '../application/compact_agenda_controller.dart'; +import '../application/compact_agenda_data.dart'; +import '../application/compact_agenda_sections.dart'; +import 'compact_agenda_formatting.dart'; +import 'schedule_item_details_popover.dart'; +import 'schedule_item_exporter.dart'; + +typedef CompactAgendaTaskCompletionCallback = + Future Function(TaskScheduleItem item, bool completed); + +const _compactAgendaMinimumLayoutSize = Size(320, 480); + +class CompactAgendaPanel extends ConsumerStatefulWidget { + const CompactAgendaPanel({ + super.key, + this.data, + this.onOpenBusyMax, + this.onNewTask, + this.onRefresh, + this.onHide, + this.onOpenItem, + this.onTaskCompletionChanged, + }); + + final AsyncValue? data; + final Future Function()? onOpenBusyMax; + final Future Function()? onNewTask; + final Future Function()? onRefresh; + final Future Function()? onHide; + final Future Function(ScheduleItem item)? onOpenItem; + final CompactAgendaTaskCompletionCallback? onTaskCompletionChanged; + + @override + ConsumerState createState() => _CompactAgendaPanelState(); +} + +class _CompactAgendaPanelState extends ConsumerState { + final _mutatingTaskKeys = {}; + var _loadedDays = compactAgendaInitialDays; + var _overdueLimit = compactAgendaInitialOverdueLimit; + var _noDateLimit = compactAgendaInitialNoDateLimit; + var _loadMoreArmed = true; + DateTime? _lastLoadedRangeEnd; + CompactAgendaData? _lastAgendaData; + bool _bodyScrolledUnderHeader = false; + bool _bodyScrolledUnderFooter = false; + bool _creatingTask = false; + bool _creatingEvent = false; + TaskScheduleItem? _editingTask; + EventEditorDraft? _editingEventDraft; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final colorScheme = Theme.of(context).colorScheme; + final AsyncValue watchedData = + widget.data ?? ref.watch(compactAgendaDataForQueryProvider(_query)); + final currentData = watchedData.valueOrNull; + if (currentData != null) { + _lastAgendaData = currentData; + if (_lastLoadedRangeEnd != currentData.range.end) { + _lastLoadedRangeEnd = currentData.range.end; + _loadMoreArmed = true; + } + } + final data = watchedData.isLoading && _lastAgendaData != null + ? AsyncData(_lastAgendaData!) + : watchedData; + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): _HideIntent(), + SingleActivator(LogicalKeyboardKey.keyR, control: true): + _RefreshIntent(), + }, + child: Actions( + actions: { + _HideIntent: CallbackAction<_HideIntent>( + onInvoke: (_) { + unawaited(_hide()); + return null; + }, + ), + _RefreshIntent: CallbackAction<_RefreshIntent>( + onInvoke: (_) { + unawaited(_refresh()); + return null; + }, + ), + }, + child: Focus( + autofocus: true, + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < + _compactAgendaMinimumLayoutSize.width || + constraints.maxHeight < + _compactAgendaMinimumLayoutSize.height) { + return const SizedBox.expand(); + } + + final child = _editingTask != null + ? _CompactAgendaTaskEditorView( + item: _editingTask!, + onClose: _closeTaskEditor, + ) + : _creatingEvent || _editingEventDraft != null + ? _CompactAgendaEventEditorView( + initialDraft: _editingEventDraft, + categorySuggestionsByAccount: + _categorySuggestionsByAccount(), + onCancel: _closeEventEditor, + onSave: _saveEvent, + onDelete: _deleteEvent, + ) + : _creatingTask + ? _CompactAgendaNewTaskView( + onCancel: _closeNewTaskEditor, + onSubmitted: _createTask, + ) + : Column( + children: [ + _CompactAgendaHeader( + data: data.valueOrNull, + onRefresh: _refresh, + onHide: _hide, + ), + Expanded( + child: Stack( + children: [ + NotificationListener( + onNotification: + _handleScrollMetricsNotification, + child: NotificationListener( + onNotification: _handleScrollNotification, + child: ColoredBox( + color: colorScheme.surface, + child: _body(data), + ), + ), + ), + _CompactAgendaScrollShadow( + visible: _bodyScrolledUnderHeader, + below: true, + ), + _CompactAgendaScrollShadow( + visible: _bodyScrolledUnderFooter, + below: false, + ), + ], + ), + ), + _CompactAgendaBottomBar( + onNewEvent: _newEvent, + onNewTask: _newTask, + ), + ], + ); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(BusyMaxRadius.window), + boxShadow: BusyMaxShadow.windowShadowsFor(context), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(BusyMaxRadius.window), + clipBehavior: Clip.antiAliasWithSaveLayer, + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.card, + border: Border.all(color: colors.border), + ), + child: child, + ), + ), + ); + }, + ), + ), + ), + ); + } + + Widget _body(AsyncValue data) { + return data.when( + loading: () => const _CompactAgendaLoadingState(), + error: (error, stackTrace) => _CompactAgendaMessageState( + icon: Icons.event_busy_outlined, + title: context.l10n.trayAgendaError, + message: redactForLog(error), + primaryLabel: context.l10n.compactAgendaRetry, + onPrimary: _refresh, + secondaryLabel: context.l10n.compactAgendaOpenBusyMax, + onSecondary: _openBusyMax, + ), + data: (agenda) { + if (!agenda.hasSignedInAccounts) { + _scheduleScrollChromeReset(); + return _CompactAgendaMessageState( + icon: Icons.login, + title: context.l10n.trayAgendaSignInRequired, + primaryLabel: context.l10n.compactAgendaOpenBusyMax, + onPrimary: _openBusyMax, + ); + } + if (!agenda.hasSources) { + _scheduleScrollChromeReset(); + return _CompactAgendaMessageState( + icon: Icons.event_busy_outlined, + title: context.l10n.trayAgendaNoSources, + primaryLabel: context.l10n.compactAgendaOpenBusyMax, + onPrimary: _openBusyMax, + ); + } + if (agenda.items.isEmpty) { + _scheduleScrollChromeReset(); + return _CompactAgendaMessageState( + icon: Icons.event_available, + title: context.l10n.compactAgendaClear, + message: context.l10n.noEventsOrTasks, + ); + } + return _sections(agenda); + }, + ); + } + + bool _handleScrollNotification(ScrollNotification notification) { + _updateScrollChrome(notification.metrics); + _maybeLoadMore(notification.metrics); + return false; + } + + bool _handleScrollMetricsNotification( + ScrollMetricsNotification notification, + ) { + _updateScrollChrome(notification.metrics); + return false; + } + + void _updateScrollChrome(ScrollMetrics metrics) { + final pixels = metrics.pixels.clamp(0.0, metrics.maxScrollExtent); + final scrolledFromTop = pixels > 0.5; + final canScrollFurtherDown = metrics.maxScrollExtent - pixels > 0.5; + _setScrollChrome( + header: scrolledFromTop, + footer: scrolledFromTop && canScrollFurtherDown, + ); + } + + void _scheduleScrollChromeReset() { + if (!_bodyScrolledUnderHeader && !_bodyScrolledUnderFooter) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _setScrollChrome(header: false, footer: false); + } + }); + } + + void _setScrollChrome({required bool header, required bool footer}) { + if (_bodyScrolledUnderHeader == header && + _bodyScrolledUnderFooter == footer) { + return; + } + setState(() { + _bodyScrolledUnderHeader = header; + _bodyScrolledUnderFooter = footer; + }); + } + + void _maybeLoadMore(ScrollMetrics metrics) { + if (widget.data != null || + !_loadMoreArmed || + metrics.axis != Axis.vertical || + metrics.extentAfter > 1) { + return; + } + final agenda = _lastAgendaData; + if (agenda == null || !agenda.hasSignedInAccounts || !agenda.hasSources) { + return; + } + _loadMoreArmed = false; + setState(() { + _loadedDays += compactAgendaPageDays; + }); + } + + Widget _sections(CompactAgendaData data) { + final sections = buildCompactAgendaSections( + today: data.today, + end: data.range.end, + items: data.items, + overdueLimit: _overdueLimit, + noDateLimit: _noDateLimit, + hasMoreOverdueTasks: data.hasMoreOverdueTasks, + hasMoreNoDateTasks: data.hasMoreNoDateTasks, + ); + return ListView.builder( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.md, + BusyMaxSpacing.sm, + BusyMaxSpacing.md, + BusyMaxSpacing.md, + ), + itemCount: sections.length, + itemBuilder: (context, index) { + final section = sections[index]; + return _CompactAgendaSectionView( + section: section, + today: data.today, + mutatingTaskKeys: _mutatingTaskKeys, + onOpenItem: _openItem, + onTaskCompletionChanged: _setTaskCompleted, + onLoadMoreOverdue: _loadMoreOverdue, + onLoadMoreNoDate: _loadMoreNoDate, + ); + }, + ); + } + + void _loadMoreOverdue() { + setState(() { + _overdueLimit += compactAgendaOverduePageSize; + }); + } + + void _loadMoreNoDate() { + setState(() { + _noDateLimit += compactAgendaNoDatePageSize; + }); + } + + Future _openBusyMax() async { + final callback = widget.onOpenBusyMax; + if (callback != null) { + await callback(); + return; + } + await const MainWindowCommandClient().openMain(); + await windowManager.hide(); + } + + Future _newTask() async { + final callback = widget.onNewTask; + if (callback != null) { + await callback(); + return; + } + setState(() { + _creatingTask = true; + _creatingEvent = false; + _editingTask = null; + _editingEventDraft = null; + _bodyScrolledUnderHeader = false; + _bodyScrolledUnderFooter = false; + }); + } + + Future _newEvent() async { + setState(() { + _creatingEvent = true; + _creatingTask = false; + _editingTask = null; + _editingEventDraft = null; + _bodyScrolledUnderHeader = false; + _bodyScrolledUnderFooter = false; + }); + } + + void _closeNewTaskEditor() { + if (!_creatingTask) { + return; + } + setState(() { + _creatingTask = false; + }); + } + + Future _createTask(NewTaskDraft draft) async { + await ref + .read(compactAgendaControllerProvider) + .createTask( + accountId: draft.accountId, + taskListId: draft.taskListId, + input: draft.input, + ); + if (!mounted) { + return; + } + setState(() { + _creatingTask = false; + }); + _invalidateAgendaData(); + } + + void _openTaskEditor(TaskScheduleItem item) { + setState(() { + _editingTask = item; + _creatingTask = false; + _creatingEvent = false; + _editingEventDraft = null; + _bodyScrolledUnderHeader = false; + _bodyScrolledUnderFooter = false; + }); + } + + void _closeTaskEditor() { + if (_editingTask == null) { + return; + } + setState(() { + _editingTask = null; + }); + _invalidateAgendaData(); + } + + void _openEventEditor(CalendarScheduleItem item) { + setState(() { + _editingEventDraft = _eventDraftFromItem(item); + _creatingEvent = false; + _creatingTask = false; + _editingTask = null; + _bodyScrolledUnderHeader = false; + _bodyScrolledUnderFooter = false; + }); + } + + void _closeEventEditor() { + if (!_creatingEvent && _editingEventDraft == null) { + return; + } + setState(() { + _creatingEvent = false; + _editingEventDraft = null; + }); + _invalidateAgendaData(); + } + + Future _saveEvent(EventEditorDraft draft) async { + await ref.read(compactAgendaControllerProvider).saveEvent(draft); + if (!mounted) { + return; + } + setState(() { + _creatingEvent = false; + _editingEventDraft = null; + }); + _invalidateAgendaData(); + } + + Future _deleteEvent(String eventId) async { + await ref.read(compactAgendaControllerProvider).deleteEvent(eventId); + if (!mounted) { + return; + } + setState(() { + _creatingEvent = false; + _editingEventDraft = null; + }); + _invalidateAgendaData(); + } + + Future _refresh() async { + final callback = widget.onRefresh; + if (callback != null) { + await callback(); + return; + } + _invalidateAgendaData(); + } + + Future _hide() async { + final callback = widget.onHide; + if (callback != null) { + await callback(); + return; + } + await windowManager.hide(); + } + + Future _openItem( + BuildContext anchorContext, + ScheduleItem item, [ + Offset? globalPosition, + ]) async { + final callback = widget.onOpenItem; + if (callback != null) { + await callback(item); + return; + } + final action = await showScheduleItemDetailsPopover( + context: context, + anchorContext: anchorContext, + item: item, + anchorPoint: globalPosition, + ); + if (!mounted || action == null) { + return; + } + switch (action) { + case ScheduleItemDetailsAction.export: + await _exportItem(item); + case ScheduleItemDetailsAction.edit: + if (item is TaskScheduleItem) { + _openTaskEditor(item); + } else if (item is CalendarScheduleItem) { + _openEventEditor(item); + } else { + await const MainWindowCommandClient().openScheduleItem(item); + await windowManager.hide(); + } + } + } + + Future _exportItem(ScheduleItem item) async { + try { + final file = await exportScheduleItemWithSaveDialog(item); + if (file == null || !mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportedFile(file.path))), + ); + } on Object catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed(redactForLog(error)))), + ); + } + } + + Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { + final key = compactAgendaTaskMutationKey(item); + if (_mutatingTaskKeys.contains(key)) { + return; + } + setState(() => _mutatingTaskKeys.add(key)); + try { + final callback = widget.onTaskCompletionChanged; + if (callback != null) { + await callback(item, completed); + } else { + await ref + .read(compactAgendaControllerProvider) + .setTaskCompleted(item, completed); + } + _invalidateAgendaData(); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); + } + } finally { + if (mounted) { + setState(() => _mutatingTaskKeys.remove(key)); + } + } + } + + void _invalidateAgendaData() { + ref.invalidate(compactAgendaDataProvider); + ref.invalidate(compactAgendaDataForQueryProvider(_query)); + } + + CompactAgendaQuery get _query { + return CompactAgendaQuery( + futureDays: _loadedDays, + overdueLimit: _overdueLimit, + noDateLimit: _noDateLimit, + ); + } + + EventEditorDraft _eventDraftFromItem(CalendarScheduleItem item) { + return EventEditorDraft.existing( + eventId: item.id, + accountId: item.accountId, + sourceId: item.sourceId, + providerCalendarId: item.providerCalendarId, + title: item.title, + allDay: item.allDay, + start: item.start, + end: item.end, + startTimeZone: item.startTimeZone, + endTimeZone: item.endTimeZone, + location: item.location, + description: item.description, + descriptionContentType: item.descriptionContentType, + descriptionHtml: item.descriptionHtml, + reminders: _eventRemindersForEdit( + item.provider, + item.reminderMinutesBeforeStart, + ), + categories: item.categories, + ); + } + + Map> _categorySuggestionsByAccount() { + final byAccount = >{}; + for (final item in _lastAgendaData?.items ?? const []) { + if (item.categories.isEmpty) { + continue; + } + byAccount + .putIfAbsent(item.accountId, () => {}) + .addAll( + item.categories + .map((category) => category.trim()) + .where((category) => category.isNotEmpty), + ); + } + return { + for (final entry in byAccount.entries) + entry.key: (entry.value.toList()..sort()), + }; + } +} + +EventEditorDraft _newEventDraft(List sources) { + final source = sources.first; + final start = _defaultNewEventStart(); + return EventEditorDraft.newEvent( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + start: start, + end: start.add(const Duration(hours: 1)), + ); +} + +DateTime _defaultNewEventStart() { + final now = DateTime.now(); + final base = DateTime(now.year, now.month, now.day, now.hour); + return now.minute < 30 + ? base.add(const Duration(minutes: 30)) + : base.add(const Duration(hours: 1)); +} + +List _editableSources( + List sources, { + required String? currentSourceId, +}) { + final visibleEditable = [ + for (final source in sources) + if (!source.isDeleted && + !source.hidden && + source.selected && + (!source.readOnly || source.id == currentSourceId)) + source, + ]; + if (visibleEditable.isNotEmpty) { + return visibleEditable; + } + return [ + for (final source in sources) + if (!source.isDeleted && + !source.hidden && + (!source.readOnly || source.id == currentSourceId)) + source, + ]; +} + +Object? _eventRemindersForEdit(BusyProvider provider, List minutes) { + final normalized = [ + for (final value in minutes) + if (value > 0) value, + ]; + if (normalized.isEmpty) { + return null; + } + if (provider == TaskProvider.google) { + return { + 'useDefault': false, + 'overrides': [ + for (final minutes in normalized) + {'method': 'popup', 'minutes': minutes}, + ], + }; + } + return {'isReminderOn': true, 'reminderMinutesBeforeStart': normalized.first}; +} + +bool _listEquals(List a, List b) { + if (identical(a, b)) { + return true; + } + if (a.length != b.length) { + return false; + } + for (var index = 0; index < a.length; index += 1) { + if (a[index] != b[index]) { + return false; + } + } + return true; +} + +class _CompactAgendaHeader extends StatelessWidget { + const _CompactAgendaHeader({ + required this.data, + required this.onRefresh, + required this.onHide, + }); + + final CompactAgendaData? data; + final Future Function() onRefresh; + final Future Function() onHide; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final subtitle = compactAgendaTodaySubtitle( + context, + data?.today ?? DateTime.now(), + ); + return DragToMoveArea( + child: Container( + height: 56, + key: const ValueKey('compactAgendaHeader'), + padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.compactAgendaTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.mutedForeground, + ), + ), + ], + ), + ), + _CompactHeaderButton( + tooltip: context.l10n.compactAgendaRefresh, + icon: Icons.refresh, + onPressed: () => unawaited(onRefresh()), + ), + const SizedBox(width: BusyMaxSpacing.xs), + YaruWindowControl( + type: YaruWindowControlType.close, + semanticLabel: context.l10n.compactAgendaHide, + onTap: () => unawaited(onHide()), + ), + ], + ), + ), + ); + } +} + +class _CompactHeaderButton extends StatelessWidget { + const _CompactHeaderButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return YaruIconButton( + tooltip: tooltip, + icon: Icon(icon), + iconSize: BusyMaxSizes.iconMd, + onPressed: onPressed, + style: busyMaxHeaderIconButtonStyle( + foregroundColor: BusyMaxSurfaceColors.of(context).foreground, + backgroundColor: busyMaxSubtleButtonBackground(context), + ), + ); + } +} + +class _CompactAgendaTaskEditorView extends ConsumerWidget { + const _CompactAgendaTaskEditorView({ + required this.item, + required this.onClose, + }); + + final TaskScheduleItem item; + final VoidCallback onClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider); + final controller = ref.read(compactAgendaControllerProvider); + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: TaskDetailsPane( + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + tasksRepositoryForAccount: (accountId) => + TasksRepository(database: database, accountId: accountId), + taskListsRepositoryForAccount: (accountId) => + TaskListsRepository(database: database, accountId: accountId), + onTaskMutationCommitted: controller.taskMutated, + onClose: onClose, + dialogBarrierColor: Colors.transparent, + ), + ); + } +} + +class _CompactAgendaEventEditorView extends ConsumerStatefulWidget { + const _CompactAgendaEventEditorView({ + required this.initialDraft, + required this.categorySuggestionsByAccount, + required this.onCancel, + required this.onSave, + required this.onDelete, + }); + + final EventEditorDraft? initialDraft; + final Map> categorySuggestionsByAccount; + final VoidCallback onCancel; + final Future Function(EventEditorDraft draft) onSave; + final Future Function(String eventId) onDelete; + + @override + ConsumerState<_CompactAgendaEventEditorView> createState() => + _CompactAgendaEventEditorViewState(); +} + +class _CompactAgendaEventEditorViewState + extends ConsumerState<_CompactAgendaEventEditorView> { + late final CalendarRepository _repository; + Stream>? _sourcesStream; + List? _sourceAccountIds; + + @override + void initState() { + super.initState(); + _repository = CalendarRepository( + database: ref.read(databaseProvider), + localTimeZone: ref.read(localTimeZoneProvider), + ); + } + + @override + Widget build(BuildContext context) { + final accounts = ref.watch(accountsStreamProvider); + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: accounts.when( + loading: () => const _CompactAgendaLoadingState(), + error: (error, stackTrace) => _CompactAgendaMessageState( + icon: Icons.event_busy_outlined, + title: context.l10n.trayAgendaError, + message: redactForLog(error), + primaryLabel: context.l10n.cancel, + onPrimary: () async => widget.onCancel(), + ), + data: (accounts) { + if (accounts.isEmpty) { + return _CompactAgendaMessageState( + icon: Icons.login, + title: context.l10n.trayAgendaSignInRequired, + primaryLabel: context.l10n.cancel, + onPrimary: () async => widget.onCancel(), + ); + } + final accountIds = [for (final account in accounts) account.id]; + return StreamBuilder>( + stream: _watchSources(accountIds), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData) { + return const _CompactAgendaLoadingState(); + } + final sources = _editableSources( + snapshot.data ?? const [], + currentSourceId: widget.initialDraft?.sourceId, + ); + if (sources.isEmpty) { + return _CompactAgendaMessageState( + icon: Icons.event_busy_outlined, + title: context.l10n.trayAgendaNoSources, + primaryLabel: context.l10n.cancel, + onPrimary: () async => widget.onCancel(), + ); + } + final draft = widget.initialDraft ?? _newEventDraft(sources); + return EventEditor( + initialDraft: draft, + sources: sources, + categorySuggestionsByAccount: + widget.categorySuggestionsByAccount, + onCancel: widget.onCancel, + onSave: (draft) => unawaited(_save(draft)), + onDelete: draft.eventId == null + ? null + : (eventId) => unawaited(_delete(eventId)), + ); + }, + ); + }, + ), + ); + } + + Stream> _watchSources(List accountIds) { + if (_sourcesStream == null || + _sourceAccountIds == null || + !_listEquals(_sourceAccountIds!, accountIds)) { + _sourceAccountIds = accountIds; + _sourcesStream = _repository + .watchSourcesForAccounts(accountIds) + .asBroadcastStream(); + } + return _sourcesStream!; + } + + Future _save(EventEditorDraft draft) async { + try { + await widget.onSave(draft); + } on Object catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); + } + } + + Future _delete(String eventId) async { + try { + await widget.onDelete(eventId); + } on Object catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); + } + } +} + +class _CompactAgendaNewTaskView extends ConsumerWidget { + const _CompactAgendaNewTaskView({ + required this.onCancel, + required this.onSubmitted, + }); + + final VoidCallback onCancel; + final Future Function(NewTaskDraft draft) onSubmitted; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(accountsStreamProvider); + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: accounts.when( + loading: () => const _CompactAgendaLoadingState(), + error: (error, stackTrace) => _CompactAgendaMessageState( + icon: Icons.event_busy_outlined, + title: context.l10n.trayAgendaError, + message: redactForLog(error), + primaryLabel: context.l10n.cancel, + onPrimary: () async => onCancel(), + ), + data: (accounts) { + if (accounts.isEmpty) { + return _CompactAgendaMessageState( + icon: Icons.login, + title: context.l10n.trayAgendaSignInRequired, + primaryLabel: context.l10n.cancel, + onPrimary: () async => onCancel(), + ); + } + return NewTaskEditorPanel( + accounts: accounts, + categorySuggestionsForAccount: (accountId) => TasksRepository( + database: ref.read(databaseProvider), + accountId: accountId, + ).watchCategorySuggestions(), + onSubmitted: onSubmitted, + onCancel: onCancel, + ); + }, + ), + ); + } +} + +class _CompactAgendaLoadingState extends StatelessWidget { + const _CompactAgendaLoadingState(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const LinearProgressIndicator(minHeight: 2), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + itemCount: 4, + separatorBuilder: (_, _) => + const SizedBox(height: BusyMaxSpacing.sm), + itemBuilder: (context, index) => const _SkeletonRow(), + ), + ), + ], + ); + } +} + +class _SkeletonRow extends StatelessWidget { + const _SkeletonRow(); + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + return Container( + height: 62, + padding: const EdgeInsets.all(BusyMaxSpacing.md), + decoration: BoxDecoration( + color: colors.control, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FractionallySizedBox( + widthFactor: 0.68, + child: Container(height: 10, color: colors.controlHover), + ), + const SizedBox(height: BusyMaxSpacing.sm), + FractionallySizedBox( + widthFactor: 0.42, + child: Container(height: 8, color: colors.controlHover), + ), + ], + ), + ); + } +} + +class _CompactAgendaMessageState extends StatelessWidget { + const _CompactAgendaMessageState({ + required this.icon, + required this.title, + this.message, + this.primaryLabel, + this.onPrimary, + this.secondaryLabel, + this.onSecondary, + }); + + final IconData icon; + final String title; + final String? message; + final String? primaryLabel; + final Future Function()? onPrimary; + final String? secondaryLabel; + final Future Function()? onSecondary; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final minHeight = constraints.maxHeight.isFinite + ? (constraints.maxHeight - BusyMaxSpacing.xl * 2) + .clamp(0.0, double.infinity) + .toDouble() + : 0.0; + return SingleChildScrollView( + padding: const EdgeInsets.all(BusyMaxSpacing.xl), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minHeight), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 34, color: colors.mutedForeground), + const SizedBox(height: BusyMaxSpacing.md), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (message != null && message!.isNotEmpty) ...[ + const SizedBox(height: BusyMaxSpacing.sm), + Text( + message!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.mutedForeground, + ), + ), + ], + if (primaryLabel != null || secondaryLabel != null) ...[ + const SizedBox(height: BusyMaxSpacing.lg), + Wrap( + alignment: WrapAlignment.center, + spacing: BusyMaxSpacing.sm, + runSpacing: BusyMaxSpacing.sm, + children: [ + if (primaryLabel != null) + BusyMaxPushButton.filled( + onPressed: onPrimary == null + ? null + : () => unawaited(onPrimary!()), + child: Text(primaryLabel!), + ), + if (secondaryLabel != null) + BusyMaxPushButton.outlined( + onPressed: onSecondary == null + ? null + : () => unawaited(onSecondary!()), + child: Text(secondaryLabel!), + ), + ], + ), + ], + ], + ), + ), + ), + ); + }, + ); + } +} + +class _CompactAgendaSectionView extends StatelessWidget { + const _CompactAgendaSectionView({ + required this.section, + required this.today, + required this.mutatingTaskKeys, + required this.onOpenItem, + required this.onTaskCompletionChanged, + required this.onLoadMoreOverdue, + required this.onLoadMoreNoDate, + }); + + final CompactAgendaSection section; + final DateTime today; + final Set mutatingTaskKeys; + final Future Function( + BuildContext anchorContext, + ScheduleItem item, [ + Offset? globalPosition, + ]) + onOpenItem; + final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; + final VoidCallback onLoadMoreOverdue; + final VoidCallback onLoadMoreNoDate; + + @override + Widget build(BuildContext context) { + final title = switch (section.kind) { + CompactAgendaSectionKind.overdue => context.l10n.compactAgendaOverdue, + CompactAgendaSectionKind.day => compactAgendaDayLabel( + context, + today: today, + day: section.day ?? today, + ), + CompactAgendaSectionKind.noDate => context.l10n.noDate, + }; + return BusyMaxGroupedList( + title: title, + filled: true, + children: [ + for (final item in section.items) + _CompactAgendaRow( + item: item, + today: today, + mutating: + item is TaskScheduleItem && + mutatingTaskKeys.contains(compactAgendaTaskMutationKey(item)), + onOpenItem: onOpenItem, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + if (section.hasMore) + _MoreBucketRow( + title: switch (section.kind) { + CompactAgendaSectionKind.overdue => + context.l10n.agendaLoadMoreOverdue, + CompactAgendaSectionKind.noDate => + context.l10n.agendaLoadMoreNoDate, + CompactAgendaSectionKind.day => + context.l10n.agendaLoadMoreOverdue, + }, + onLoadMore: switch (section.kind) { + CompactAgendaSectionKind.overdue => onLoadMoreOverdue, + CompactAgendaSectionKind.noDate => onLoadMoreNoDate, + CompactAgendaSectionKind.day => onLoadMoreOverdue, + }, + ), + ], + ); + } +} + +class _CompactAgendaRow extends StatelessWidget { + const _CompactAgendaRow({ + required this.item, + required this.today, + required this.mutating, + required this.onOpenItem, + required this.onTaskCompletionChanged, + }); + + final ScheduleItem item; + final DateTime today; + final bool mutating; + final Future Function( + BuildContext anchorContext, + ScheduleItem item, [ + Offset? globalPosition, + ]) + onOpenItem; + final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; + + @override + Widget build(BuildContext context) { + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + Offset? pointerDownPosition; + return AnimatedOpacity( + opacity: mutating ? 0.48 : 1, + duration: const Duration(milliseconds: 120), + child: BusyMaxActionRow( + title: item.title, + titleWidget: _CompactAgendaRowTitle(item: item), + subtitleWidget: _CompactAgendaRowSubtitle(item: item, today: today), + leading: _CompactAgendaRowMarker(item: item), + trailing: task == null + ? null + : YaruCheckbox( + value: task.completed, + onChanged: mutating + ? null + : (value) => unawaited( + onTaskCompletionChanged(task, value ?? false), + ), + ), + enabled: !mutating, + onPointerDown: (position) => pointerDownPosition = position, + onTap: () => unawaited(onOpenItem(context, item, pointerDownPosition)), + ), + ); + } +} + +class _CompactAgendaRowMarker extends StatelessWidget { + const _CompactAgendaRowMarker({required this.item}); + + final ScheduleItem item; + + @override + Widget build(BuildContext context) { + final isTask = item.kind == ScheduleItemKind.task; + final color = isTask + ? Theme.of(context).colorScheme.onSurfaceVariant + : ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); + final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; + return Icon(icon, size: BusyMaxSizes.iconSm, color: color); + } +} + +class _CompactAgendaRowTitle extends StatelessWidget { + const _CompactAgendaRowTitle({required this.item}); + + final ScheduleItem item; + + @override + Widget build(BuildContext context) { + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + return Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + decoration: task?.completed == true ? TextDecoration.lineThrough : null, + ), + ); + } +} + +class _CompactAgendaRowSubtitle extends StatelessWidget { + const _CompactAgendaRowSubtitle({required this.item, required this.today}); + + final ScheduleItem item; + final DateTime today; + + @override + Widget build(BuildContext context) { + final surfaceColors = BusyMaxSurfaceColors.of(context); + final source = ScheduleProjection.sourceLabelForScheduleItem(item); + final meta = compactAgendaItemMeta(context, item, today: today); + final event = item is CalendarScheduleItem + ? item as CalendarScheduleItem + : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + [if (meta.isNotEmpty) meta, source].join(' - '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: surfaceColors.mutedForeground), + ), + if (event?.location?.trim().isNotEmpty == true) + Text( + event!.location!.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: surfaceColors.mutedForeground, + ), + ), + ], + ); + } +} + +class _MoreBucketRow extends StatelessWidget { + const _MoreBucketRow({required this.title, required this.onLoadMore}); + + final String title; + final VoidCallback onLoadMore; + + @override + Widget build(BuildContext context) { + return BusyMaxActionRow( + title: title, + leading: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), + onTap: onLoadMore, + ); + } +} + +class _CompactAgendaBottomBar extends StatelessWidget { + const _CompactAgendaBottomBar({ + required this.onNewEvent, + required this.onNewTask, + }); + + final Future Function() onNewEvent; + final Future Function() onNewTask; + + @override + Widget build(BuildContext context) { + return Container( + key: const ValueKey('compactAgendaFooter'), + padding: const EdgeInsets.all(BusyMaxSpacing.md), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: Row( + children: [ + Expanded( + child: BusyMaxPushButton.filled( + onPressed: () => unawaited(onNewEvent()), + child: Text(context.l10n.newEvent), + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: BusyMaxPushButton.filled( + onPressed: () => unawaited(onNewTask()), + child: Text(context.l10n.compactAgendaNewTask), + ), + ), + ], + ), + ); + } +} + +class _CompactAgendaScrollShadow extends StatelessWidget { + const _CompactAgendaScrollShadow({ + required this.visible, + required this.below, + }); + + final bool visible; + final bool below; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedOpacity( + key: ValueKey( + below + ? 'compactAgendaTopScrollShadow' + : 'compactAgendaBottomScrollShadow', + ), + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 120), + child: Align( + alignment: below ? Alignment.topCenter : Alignment.bottomCenter, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: BusyMaxShadow.edgeShadowsFor(context, below: below), + ), + child: const SizedBox(width: double.infinity, height: 1), + ), + ), + ), + ); + } +} + +class _HideIntent extends Intent { + const _HideIntent(); +} + +class _RefreshIntent extends Intent { + const _RefreshIntent(); +} diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 28c9c1e..01b1402 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -3,140 +3,188 @@ import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_yaru_theme.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; +import '../../../schedule/schedule_sorting.dart'; import 'schedule_event_block.dart'; +import 'schedule_item_selection.dart'; -class ScheduleAgendaView extends StatelessWidget { +class ScheduleAgendaView extends StatefulWidget { const ScheduleAgendaView({ super.key, required this.range, required this.items, required this.onItemSelected, required this.onTaskCompletionChanged, + this.hasMoreOverdueTasks = false, + this.hasMoreNoDateTasks = false, + this.onLoadMore, + this.onLoadMoreOverdue, + this.onLoadMoreNoDate, + this.onItemAnchorAvailable, }); final ScheduleRange range; final List items; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; + final bool hasMoreOverdueTasks; + final bool hasMoreNoDateTasks; + final VoidCallback? onLoadMore; + final VoidCallback? onLoadMoreOverdue; + final VoidCallback? onLoadMoreNoDate; + final ScheduleItemAnchorCallback? onItemAnchorAvailable; + + @override + State createState() => _ScheduleAgendaViewState(); +} + +class _ScheduleAgendaViewState extends State { + var _loadMoreArmed = true; + + @override + void didUpdateWidget(covariant ScheduleAgendaView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.range.end != widget.range.end) { + _loadMoreArmed = true; + } + } + + bool _handleScroll(ScrollNotification notification) { + final onLoadMore = widget.onLoadMore; + if (!_loadMoreArmed || onLoadMore == null) { + return false; + } + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification.metrics.extentAfter > 1) { + return false; + } + _loadMoreArmed = false; + onLoadMore(); + return false; + } @override Widget build(BuildContext context) { - final dated = items.where((item) => item.start != null).toList(); - final noDateTasks = ScheduleProjection.noDateTasks(items); + final dated = widget.items.where((item) => item.start != null).toList(); + final noDateTasks = ScheduleProjection.noDateTasks(widget.items); final groups = ScheduleProjection.groupByDay(dated); - final rangeStart = ScheduleProjection.day(range.start); - final rangeEnd = ScheduleProjection.day(range.end); + final rangeStart = ScheduleProjection.day(widget.range.start); + final rangeEnd = ScheduleProjection.day(widget.range.end); + final overdueTasks = dated.whereType().where((item) { + final start = item.start; + return start != null && + !item.completed && + ScheduleProjection.day(start).isBefore(rangeStart); + }).toList()..sort(compareScheduleItems); final days = groups.keys .where((day) => !day.isBefore(rangeStart) && day.isBefore(rangeEnd)) .toList() ..sort(); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: ListView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.lg, - BusyMaxSpacing.md, - BusyMaxSpacing.lg, - BusyMaxSpacing.xl, - ), - children: [ - for (final day in days) ...[ - _AgendaDayHeader(day: day), - const SizedBox(height: BusyMaxSpacing.sm), - for (final item in groups[day]!) - Padding( - padding: const EdgeInsets.only(bottom: BusyMaxSpacing.sm), - child: _AgendaRow( - item: item, - onTap: (context) => onItemSelected(context, item), - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => onTaskCompletionChanged(item, completed) - : null, - ), + return NotificationListener( + onNotification: _handleScroll, + child: ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: ListView( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.md, + BusyMaxSpacing.lg, + BusyMaxSpacing.xl, + ), + children: [ + if (overdueTasks.isNotEmpty) + BusyMaxGroupedList( + title: context.l10n.overdue, + filled: true, + children: [ + for (final item in overdueTasks) + _AgendaRow( + item: item, + onAnchorAvailable: widget.onItemAnchorAvailable, + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: (completed) => + widget.onTaskCompletionChanged(item, completed), + ), + if (widget.hasMoreOverdueTasks && + widget.onLoadMoreOverdue != null) + _AgendaLoadMoreRow( + title: context.l10n.agendaLoadMoreOverdue, + onTap: widget.onLoadMoreOverdue!, + ), + ], ), - const SizedBox(height: BusyMaxSpacing.md), - ], - if (noDateTasks.isNotEmpty) ...[ - _AgendaPlainHeader(title: context.l10n.noDate), - const SizedBox(height: BusyMaxSpacing.sm), - for (final item in noDateTasks) - Padding( - padding: const EdgeInsets.only(bottom: BusyMaxSpacing.sm), - child: _AgendaRow( - item: item, - onTap: (context) => onItemSelected(context, item), - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => onTaskCompletionChanged(item, completed) - : null, - ), + if (noDateTasks.isNotEmpty) + BusyMaxGroupedList( + title: context.l10n.noDate, + filled: true, + children: [ + for (final item in noDateTasks) + _AgendaRow( + item: item, + onAnchorAvailable: widget.onItemAnchorAvailable, + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + widget.onTaskCompletionChanged(item, completed) + : null, + ), + if (widget.hasMoreNoDateTasks && + widget.onLoadMoreNoDate != null) + _AgendaLoadMoreRow( + title: context.l10n.agendaLoadMoreNoDate, + onTap: widget.onLoadMoreNoDate!, + ), + ], + ), + for (final day in days) + BusyMaxGroupedList( + title: _dayLabel(context, day), + filled: true, + children: [ + for (final item in groups[day]!) + _AgendaRow( + item: item, + onAnchorAvailable: widget.onItemAnchorAvailable, + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + widget.onTaskCompletionChanged(item, completed) + : null, + ), + ], ), ], - ], - ), - ); - } -} - -class _AgendaDayHeader extends StatelessWidget { - const _AgendaDayHeader({required this.day}); - - final DateTime day; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final today = DateUtils.isSameDay(day, DateTime.now()); - final label = _dayLabel(context, day); - return Row( - children: [ - Container( - width: 34, - height: 30, - alignment: Alignment.center, - decoration: BoxDecoration( - color: today ? colorScheme.primary : colorScheme.surface, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: today - ? null - : Border.all(color: busyMaxPanelBorder(context)), - ), - child: Text( - '${day.day}', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: today ? colorScheme.onPrimary : colorScheme.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: busyMaxSectionHeaderStyle(context), - ), ), - ], + ), ); } } -class _AgendaPlainHeader extends StatelessWidget { - const _AgendaPlainHeader({required this.title}); +class _AgendaLoadMoreRow extends StatelessWidget { + const _AgendaLoadMoreRow({required this.title, required this.onTap}); final String title; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Text(title, style: busyMaxSectionHeaderStyle(context)); + return BusyMaxActionRow( + title: title, + leading: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), + onTap: onTap, + ); } } @@ -144,95 +192,107 @@ class _AgendaRow extends StatelessWidget { const _AgendaRow({ required this.item, required this.onTap, + this.onAnchorAvailable, this.onTaskCompletionChanged, }); final ScheduleItem item; - final ValueChanged onTap; + final ScheduleItemTapCallback onTap; + final ScheduleItemAnchorCallback? onAnchorAvailable; final ValueChanged? onTaskCompletionChanged; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final color = ScheduleProjection.colorForItem(item, colorScheme.brightness); final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + Offset? pointerDownPosition; + final onAnchorAvailable = this.onAnchorAvailable; + if (onAnchorAvailable != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + onAnchorAvailable(item, context); + } + }); + } - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - onTap: () => onTap(context), - child: Container( - constraints: const BoxConstraints(minHeight: 52), - padding: const EdgeInsets.all(BusyMaxSpacing.sm), - decoration: BoxDecoration( - color: Color.alphaBlend( - color.withValues( - alpha: item.kind == ScheduleItemKind.task ? 0.08 : 0.12, - ), - colorScheme.surface, + return BusyMaxActionRow( + title: item.title, + titleWidget: _AgendaItemTitle(item: item), + subtitleWidget: _AgendaItemSubtitle(item: item), + leading: _AgendaItemMarker(item: item), + trailing: task == null + ? null + : YaruCheckbox( + value: task.completed, + onChanged: onTaskCompletionChanged == null + ? null + : (value) => onTaskCompletionChanged!(value ?? false), ), - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border(left: BorderSide(color: color, width: 4)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 76, - child: Text( - scheduleTimeRange(context, item), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - if (task != null) ...[ - YaruCheckbox( - value: task.completed, - onChanged: onTaskCompletionChanged == null - ? null - : (value) => onTaskCompletionChanged!(value ?? false), - ), - const SizedBox(width: BusyMaxSpacing.xs), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - decoration: task?.completed == true - ? TextDecoration.lineThrough - : null, - color: task?.completed == true - ? colorScheme.onSurfaceVariant - : colorScheme.onSurface, - ), - ), - Text( - ScheduleProjection.sourceLabelForScheduleItem(item), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - ), + onPointerDown: (position) => pointerDownPosition = position, + onTap: () => onTap(context, pointerDownPosition), + ); + } +} + +class _AgendaItemMarker extends StatelessWidget { + const _AgendaItemMarker({required this.item}); + + final ScheduleItem item; + + @override + Widget build(BuildContext context) { + final isTask = item.kind == ScheduleItemKind.task; + final color = isTask + ? Theme.of(context).colorScheme.onSurfaceVariant + : ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); + final icon = isTask ? YaruIcons.task_list : YaruIcons.calendar; + return Icon(icon, size: BusyMaxSizes.iconSm, color: color); + } +} + +class _AgendaItemTitle extends StatelessWidget { + const _AgendaItemTitle({required this.item}); + + final ScheduleItem item; + + @override + Widget build(BuildContext context) { + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final colorScheme = Theme.of(context).colorScheme; + return Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + decoration: task?.completed == true ? TextDecoration.lineThrough : null, + color: task?.completed == true + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + ); + } +} + +class _AgendaItemSubtitle extends StatelessWidget { + const _AgendaItemSubtitle({required this.item}); + + final ScheduleItem item; + + @override + Widget build(BuildContext context) { + final values = [ + scheduleTimeRange(context, item), + ScheduleProjection.sourceLabelForScheduleItem(item), + ].where((value) => value.trim().isNotEmpty).join(' - '); + return Text( + values, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: BusyMaxSurfaceColors.of(context).mutedForeground, ), ); } diff --git a/lib/src/features/schedule/presentation/schedule_day_week_view.dart b/lib/src/features/schedule/presentation/schedule_day_week_view.dart index aebe00b..2ca6c81 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -1,13 +1,26 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; import 'package:intl/intl.dart'; +import '../../../app/app_settings.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_surface_colors.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; +import 'schedule_event_block.dart'; import 'schedule_item_chip.dart'; +import 'schedule_item_selection.dart'; + +const _fullDayBarDefaultHeight = 82.0; +const _fullDayBarMinHeight = 82.0; +const _fullDayBarMaxHeight = 260.0; +const _allDayResizeHandleHeight = 22.0; +const _timesIndicatorsWidth = 64.0; +const _defaultHeightPerMinute = 0.9; class ScheduleDayWeekView extends StatefulWidget { const ScheduleDayWeekView({ @@ -20,6 +33,8 @@ class ScheduleDayWeekView extends StatefulWidget { required this.onEmptySlot, required this.onItemSelected, required this.onTaskCompletionChanged, + this.dayStartMinute = defaultScheduleDayStartMinute, + this.dayEndMinute = defaultScheduleDayEndMinute, }); final ScheduleRange range; @@ -28,9 +43,11 @@ class ScheduleDayWeekView extends StatefulWidget { final List items; final ValueChanged onDaySelected; final ValueChanged onEmptySlot; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; + final int dayStartMinute; + final int dayEndMinute; @override State createState() => _ScheduleDayWeekViewState(); @@ -39,11 +56,14 @@ class ScheduleDayWeekView extends StatefulWidget { class _ScheduleDayWeekViewState extends State { late final icv.EventsController _controller; final _plannerKey = GlobalKey(); + var _fullDayBarHeight = _fullDayBarDefaultHeight; + var _heightPerMinute = _defaultHeightPerMinute; @override void initState() { super.initState(); _controller = icv.EventsController(); + _jumpToVisibleDayStart(); } @override @@ -60,6 +80,10 @@ class _ScheduleDayWeekViewState extends State { !_sameDay(_plannerStartDate(oldWidget), _plannerStartDate(widget))) { _jumpToDate(_plannerStartDate(widget)); } + if (oldWidget.dayStartMinute != widget.dayStartMinute || + oldWidget.dayEndMinute != widget.dayEndMinute) { + _jumpToVisibleDayStart(); + } } @override @@ -78,17 +102,19 @@ class _ScheduleDayWeekViewState extends State { colorScheme.surface, ); final showFullDayBar = _hasRenderedFullDayEvents(context, widget); - final fullDayBarHeight = showFullDayBar ? 82.0 : 0.0; + final fullDayBarHeight = showFullDayBar ? _fullDayBarHeight : 0.0; + final daysHeaderHeight = widget.daysShowed == 1 ? 0.0 : 50.0; + final visibleMinutes = _visibleMinuteRange(widget); - return icv.EventsPlanner( + final planner = icv.EventsPlanner( key: _plannerKey, controller: _controller, initialDate: _plannerStartDate(widget), daysShowed: widget.daysShowed, maxPreviousDays: 730, maxNextDays: 730, - heightPerMinute: 0.9, - initialVerticalScrollOffset: 0.9 * 7 * 60, + heightPerMinute: _heightPerMinute, + initialVerticalScrollOffset: visibleMinutes.start * _heightPerMinute, daySeparationWidth: 1, dayEventsArranger: const icv.SideEventArranger( paddingLeft: 4, @@ -121,7 +147,30 @@ class _ScheduleDayWeekViewState extends State { border: Border(bottom: BorderSide(color: borderColor)), ), fullDayBackgroundColor: colorScheme.surface, + fullDayEventsBuilder: (events, width) { + return _FullDayScrollPane( + events: events, + height: fullDayBarHeight, + width: width, + onItemSelected: widget.onItemSelected, + onTaskCompletionChanged: widget.onTaskCompletionChanged, + ); + }, fullDayEventBuilder: (event, width) { + final group = _groupFrom(event); + if (group != null) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _SameSlotItemsStrip( + items: group.items, + height: 24, + width: width, + compact: true, + onItemSelected: widget.onItemSelected, + onTaskCompletionChanged: widget.onTaskCompletionChanged, + ), + ); + } final item = _itemFrom(event); if (item == null) { return const SizedBox.shrink(); @@ -133,7 +182,8 @@ class _ScheduleDayWeekViewState extends State { height: 24, width: width, compact: true, - onTap: (context) => widget.onItemSelected(context, item), + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => widget.onTaskCompletionChanged(item, completed) @@ -160,6 +210,17 @@ class _ScheduleDayWeekViewState extends State { drawVerticalLeftLine: true, ), dayEventBuilder: (event, height, width, heightPerMinute) { + final group = _groupFrom(event); + if (group != null) { + return _SameSlotItemsStrip( + items: group.items, + height: height, + width: width, + compact: height < 36, + onItemSelected: widget.onItemSelected, + onTaskCompletionChanged: widget.onTaskCompletionChanged, + ); + } final item = _itemFrom(event); if (item == null) { return const SizedBox.shrink(); @@ -169,7 +230,8 @@ class _ScheduleDayWeekViewState extends State { height: height, width: width, compact: height < 36, - onTap: (context) => widget.onItemSelected(context, item), + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => widget.onTaskCompletionChanged(item, completed) : null, @@ -177,7 +239,7 @@ class _ScheduleDayWeekViewState extends State { }, ), timesIndicatorsParam: icv.TimesIndicatorsParam( - timesIndicatorsWidth: 64, + timesIndicatorsWidth: _timesIndicatorsWidth, timesIndicatorsHorizontalPadding: 6, timesIndicatorsCustomPainter: (heightPerMinute) => icv.HoursPainter( heightPerMinute: heightPerMinute, @@ -208,23 +270,73 @@ class _ScheduleDayWeekViewState extends State { ), ), offTimesParam: icv.OffTimesParam( + offTimesAllDaysRanges: _offTimeRanges( + startMinute: widget.dayStartMinute, + endMinute: widget.dayEndMinute, + ), offTimesColor: Color.alphaBlend( colorScheme.onSurface.withValues(alpha: 0.025), colorScheme.surface, ), + offTimesAllDaysPainter: + (column, day, isToday, heightPerMinute, ranges, color) => + icv.OffSetAllDaysPainter( + isToday, + heightPerMinute, + ranges, + color, + paintToday: true, + ), ), - pinchToZoomParam: const icv.PinchToZoomParameters( + pinchToZoomParam: icv.PinchToZoomParameters( pinchToZoomMinHeightPerMinute: 0.6, pinchToZoomMaxHeightPerMinute: 1.6, + onZoomChange: (heightPerMinute) { + setState(() => _heightPerMinute = heightPerMinute); + }, ), ); + if (!showFullDayBar) { + return planner; + } + return Stack( + clipBehavior: Clip.none, + children: [ + planner, + Positioned( + top: + daysHeaderHeight + + fullDayBarHeight - + _allDayResizeHandleHeight / 2, + left: _timesIndicatorsWidth, + right: 0, + child: Center( + child: _AllDayResizeHandle( + onTap: _toggleFullDayBarHeight, + onVerticalDragUpdate: (delta) { + setState(() { + _fullDayBarHeight = (_fullDayBarHeight + delta) + .clamp(_fullDayBarMinHeight, _fullDayBarMaxHeight) + .toDouble(); + }); + }, + ), + ), + ), + ], + ); + } + + void _toggleFullDayBarHeight() { + setState(() { + _fullDayBarHeight = _fullDayBarHeight <= _fullDayBarDefaultHeight + 1 + ? 168.0 + : _fullDayBarDefaultHeight; + }); } void _reloadEvents() { - final events = widget.items - .map((item) => _ScheduleIcvEvent.fromItem(context, item)) - .nonNulls - .toList(); + final events = _ScheduleIcvEvent.fromItems(context, widget.items); _controller.updateCalendarData((calendarData) { calendarData.clearAll(); calendarData.addEvents(events); @@ -239,6 +351,18 @@ class _ScheduleDayWeekViewState extends State { _plannerKey.currentState?.jumpToDate(date); }); } + + void _jumpToVisibleDayStart() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final visibleMinutes = _visibleMinuteRange(widget); + _plannerKey.currentState?.updateVerticalScrollOffset( + visibleMinutes.start * _heightPerMinute, + ); + }); + } } DateTime _plannerStartDate(ScheduleDayWeekView widget) { @@ -253,6 +377,101 @@ bool _sameDay(DateTime left, DateTime right) { left.day == right.day; } +({int start, int end}) _visibleMinuteRange(ScheduleDayWeekView widget) { + var start = _validDayStartMinute(widget.dayStartMinute); + var end = _validDayEndMinute(widget.dayEndMinute, start); + final range = widget.range; + + for (final item in widget.items) { + if (item.allDay || item.start == null) { + continue; + } + final itemStart = item.start!; + final itemEnd = item.end != null && item.end!.isAfter(itemStart) + ? item.end! + : itemStart.add(const Duration(minutes: 30)); + if (!itemStart.isBefore(range.end) || !itemEnd.isAfter(range.start)) { + continue; + } + + final firstDay = _day( + itemStart.isAfter(range.start) ? itemStart : range.start, + ); + final lastInstant = itemEnd.isBefore(range.end) ? itemEnd : range.end; + final lastDay = _day(lastInstant); + + for ( + var day = firstDay; + !day.isAfter(lastDay) && day.isBefore(range.end); + day = day.add(const Duration(days: 1)) + ) { + final nextDay = day.add(const Duration(days: 1)); + final segmentStart = itemStart.isAfter(day) ? itemStart : day; + final segmentEnd = itemEnd.isBefore(nextDay) ? itemEnd : nextDay; + if (!segmentStart.isBefore(segmentEnd)) { + continue; + } + start = math.min(start, _minuteOfDay(segmentStart)); + end = math.max(end, _endMinuteOfDay(segmentEnd, day)); + } + } + + return ( + start: start, + end: math.max(start + 60, end).clamp(1, 24 * 60).toInt(), + ); +} + +int _validDayStartMinute(int minute) { + if (minute < 0 || minute >= 24 * 60) { + return defaultScheduleDayStartMinute; + } + return minute; +} + +int _validDayEndMinute(int minute, int startMinute) { + if (minute <= startMinute || minute > 24 * 60) { + return math.min(startMinute + 60, 24 * 60); + } + return minute; +} + +int _minuteOfDay(DateTime value) => value.hour * 60 + value.minute; + +int _endMinuteOfDay(DateTime value, DateTime day) { + if (!value.isBefore(day.add(const Duration(days: 1)))) { + return 24 * 60; + } + return _minuteOfDay(value); +} + +List _offTimeRanges({ + required int startMinute, + required int endMinute, +}) { + final start = _validDayStartMinute(startMinute); + final end = _validDayEndMinute(endMinute, start); + return [ + if (start > 0) + icv.OffTimeRange( + const TimeOfDay(hour: 0, minute: 0), + _timeOfDayFromMinute(start), + ), + if (end < 24 * 60) + icv.OffTimeRange( + _timeOfDayFromMinute(end), + const TimeOfDay(hour: 24, minute: 0), + ), + ]; +} + +TimeOfDay _timeOfDayFromMinute(int minute) { + if (minute >= 24 * 60) { + return const TimeOfDay(hour: 24, minute: 0); + } + return TimeOfDay(hour: minute ~/ 60, minute: minute % 60); +} + class _PlannerDayHeader extends StatelessWidget { const _PlannerDayHeader({required this.day, required this.isToday}); @@ -304,9 +523,334 @@ class _PlannerDayHeader extends StatelessWidget { } } +class _FullDayScrollPane extends StatefulWidget { + const _FullDayScrollPane({ + required this.events, + required this.height, + required this.width, + required this.onItemSelected, + required this.onTaskCompletionChanged, + }); + + final List events; + final double height; + final double width; + final ScheduleItemSelectionCallback onItemSelected; + final void Function(TaskScheduleItem item, bool completed) + onTaskCompletionChanged; + + @override + State<_FullDayScrollPane> createState() => _FullDayScrollPaneState(); +} + +class _FullDayScrollPaneState extends State<_FullDayScrollPane> { + late final ScrollController _controller; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const eventTopPadding = 2.0; + final contentHeight = widget.events.length * (24 + eventTopPadding); + final needsScroll = contentHeight > widget.height; + return SizedBox( + height: widget.height, + width: widget.width, + child: Scrollbar( + controller: _controller, + thumbVisibility: needsScroll, + trackVisibility: false, + thickness: 4, + radius: const Radius.circular(999), + child: SingleChildScrollView( + key: const ValueKey('schedule-all-day-scroll'), + controller: _controller, + scrollDirection: Axis.vertical, + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.only(bottom: _allDayResizeHandleHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + for (final event in widget.events) + Padding( + padding: const EdgeInsets.only(top: eventTopPadding), + child: _FullDayEventTile( + event: event, + width: widget.width, + onItemSelected: widget.onItemSelected, + onTaskCompletionChanged: widget.onTaskCompletionChanged, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _FullDayEventTile extends StatelessWidget { + const _FullDayEventTile({ + required this.event, + required this.width, + required this.onItemSelected, + required this.onTaskCompletionChanged, + }); + + final icv.Event event; + final double width; + final ScheduleItemSelectionCallback onItemSelected; + final void Function(TaskScheduleItem item, bool completed) + onTaskCompletionChanged; + + @override + Widget build(BuildContext context) { + final group = _groupFrom(event); + if (group != null) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _SameSlotItemsStrip( + items: group.items, + height: 24, + width: width, + compact: true, + onItemSelected: onItemSelected, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + ); + } + final item = _itemFrom(event); + if (item == null) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: ScheduleItemChip( + item: item, + height: 24, + width: width, + compact: true, + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => onTaskCompletionChanged(item, completed) + : null, + ), + ); + } +} + +class _AllDayResizeHandle extends StatelessWidget { + const _AllDayResizeHandle({ + required this.onTap, + required this.onVerticalDragUpdate, + }); + + final VoidCallback onTap; + final ValueChanged onVerticalDragUpdate; + + @override + Widget build(BuildContext context) { + final surfaceColors = BusyMaxSurfaceColors.of(context); + final colorScheme = Theme.of(context).colorScheme; + return Tooltip( + message: 'Resize all-day panel', + child: Semantics( + button: true, + label: 'Resize all-day panel', + child: MouseRegion( + cursor: SystemMouseCursors.resizeUpDown, + child: GestureDetector( + key: const ValueKey('schedule-all-day-resize-handle'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + onVerticalDragUpdate: (details) { + onVerticalDragUpdate(details.delta.dy); + }, + child: SizedBox( + width: 68, + height: _allDayResizeHandleHeight, + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: surfaceColors.control, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: busyMaxPanelBorder(context)), + boxShadow: [ + BoxShadow( + color: BusyMaxShadow.floatingColor(context), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: SizedBox( + width: 52, + height: 16, + child: Icon( + Icons.drag_handle, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SameSlotItemsStrip extends StatefulWidget { + const _SameSlotItemsStrip({ + required this.items, + required this.height, + required this.width, + required this.compact, + required this.onItemSelected, + required this.onTaskCompletionChanged, + }); + + final List items; + final double height; + final double width; + final bool compact; + final ScheduleItemSelectionCallback onItemSelected; + final void Function(TaskScheduleItem item, bool completed) + onTaskCompletionChanged; + + @override + State<_SameSlotItemsStrip> createState() => _SameSlotItemsStripState(); +} + +class _SameSlotItemsStripState extends State<_SameSlotItemsStrip> { + late final ScrollController _controller; + + @override + void initState() { + super.initState(); + _controller = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final width = scheduleSafeBlockWidth(widget.width) ?? 0; + final height = scheduleSafeBlockHeight(widget.height); + final gap = widget.compact ? 3.0 : BusyMaxSpacing.xs; + final minChipWidth = widget.compact ? 132.0 : 176.0; + final visibleChipCount = math.min(widget.items.length, 2); + final visibleWidth = width - (visibleChipCount - 1) * gap; + final chipWidth = math.max(minChipWidth, visibleWidth / visibleChipCount); + final needsScroll = + widget.items.length * chipWidth + (widget.items.length - 1) * gap > + width; + + return SizedBox( + width: width, + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + child: Scrollbar( + controller: _controller, + thumbVisibility: needsScroll, + trackVisibility: false, + thickness: 3, + radius: const Radius.circular(999), + child: SingleChildScrollView( + controller: _controller, + scrollDirection: Axis.horizontal, + physics: const ClampingScrollPhysics(), + child: Row( + children: [ + for (var index = 0; index < widget.items.length; index++) ...[ + ScheduleItemChip( + item: widget.items[index], + height: height, + width: chipWidth, + compact: widget.compact, + onTap: (context, [globalPosition]) => widget.onItemSelected( + context, + widget.items[index], + globalPosition, + ), + onTaskCompletionChanged: + widget.items[index] is TaskScheduleItem + ? (completed) => widget.onTaskCompletionChanged( + widget.items[index] as TaskScheduleItem, + completed, + ) + : null, + ), + if (index != widget.items.length - 1) SizedBox(width: gap), + ], + ], + ), + ), + ), + ), + ); + } +} + class _ScheduleIcvEvent { const _ScheduleIcvEvent._(); + static List fromItems( + BuildContext context, + List items, + ) { + final grouped = >{}; + final ungrouped = []; + for (final item in items) { + final event = fromItem(context, item); + if (event == null) { + continue; + } + if (event.isFullDay) { + ungrouped.add(event); + continue; + } + grouped + .putIfAbsent( + _slotKey(event), + () => <({icv.Event event, ScheduleItem item})>[], + ) + .add((event: event, item: item)); + } + + return [ + ...ungrouped, + for (final entries in grouped.values) + if (entries.length == 1) + entries.single.event + else + entries.first.event.copyWith( + title: entries.first.item.title, + description: '${entries.length} items', + data: _ScheduleSlotGroup([for (final entry in entries) entry.item]), + eventType: _ScheduleSlotGroup, + ), + ]; + } + static icv.Event? fromItem(BuildContext context, ScheduleItem item) { final start = item.start; if (start == null) { @@ -354,10 +898,32 @@ class _ScheduleIcvEvent { } } +class _ScheduleSlotGroup { + const _ScheduleSlotGroup(this.items); + + final List items; +} + ScheduleItem? _itemFrom(icv.Event event) { return event.data is ScheduleItem ? event.data! as ScheduleItem : null; } +_ScheduleSlotGroup? _groupFrom(icv.Event event) { + return event.data is _ScheduleSlotGroup + ? event.data! as _ScheduleSlotGroup + : null; +} + +String _slotKey(icv.Event event) { + final end = event.endTime?.microsecondsSinceEpoch ?? -1; + return [ + event.columnIndex, + event.isFullDay, + event.startTime.microsecondsSinceEpoch, + end, + ].join('|'); +} + Color _foregroundFor(Color color) { return color.computeLuminance() > 0.54 ? Colors.black : Colors.white; } diff --git a/lib/src/features/schedule/presentation/schedule_event_block.dart b/lib/src/features/schedule/presentation/schedule_event_block.dart index bdeba21..a8068e3 100644 --- a/lib/src/features/schedule/presentation/schedule_event_block.dart +++ b/lib/src/features/schedule/presentation/schedule_event_block.dart @@ -4,6 +4,7 @@ import '../../../app/busymax_design.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import 'schedule_item_selection.dart'; class ScheduleEventBlock extends StatelessWidget { const ScheduleEventBlock({ @@ -19,7 +20,7 @@ class ScheduleEventBlock extends StatelessWidget { final double height; final double? width; final bool compact; - final ValueChanged? onTap; + final ScheduleItemTapCallback? onTap; @override Widget build(BuildContext context) { @@ -39,9 +40,13 @@ class ScheduleEventBlock extends StatelessWidget { final titleMaxLines = showTime || contentHeight < 36 ? 1 : 2; final tooltipDetails = _tooltipDetails(context); + Offset? pointerDownPosition; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onTap == null ? null : () => onTap!(context), + onTapDown: onTap == null + ? null + : (details) => pointerDownPosition = details.globalPosition, + onTap: onTap == null ? null : () => onTap!(context, pointerDownPosition), child: Tooltip( message: tooltipDetails.isEmpty ? item.title diff --git a/lib/src/features/schedule/presentation/schedule_item_chip.dart b/lib/src/features/schedule/presentation/schedule_item_chip.dart index 33b7f45..7457da9 100644 --- a/lib/src/features/schedule/presentation/schedule_item_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_item_chip.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../schedule/schedule_item.dart'; import 'schedule_event_block.dart'; +import 'schedule_item_selection.dart'; import 'schedule_task_chip.dart'; class ScheduleItemChip extends StatelessWidget { @@ -19,7 +20,7 @@ class ScheduleItemChip extends StatelessWidget { final double height; final double? width; final bool compact; - final ValueChanged? onTap; + final ScheduleItemTapCallback? onTap; final ValueChanged? onTaskCompletionChanged; @override diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index 88e6016..354c6a9 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -18,8 +18,11 @@ Future showScheduleItemDetailsPopover({ required BuildContext context, required BuildContext anchorContext, required ScheduleItem item, + Offset? anchorPoint, }) { - final anchorRect = _globalRectFor(anchorContext); + final anchorRect = anchorPoint == null + ? _globalRectFor(anchorContext) + : _globalRectForPoint(anchorPoint); return showGeneralDialog( context: context, barrierDismissible: true, @@ -68,7 +71,7 @@ class _ScheduleItemDetailsPopover extends StatelessWidget { child: SafeArea( child: LayoutBuilder( builder: (context, constraints) { - final position = _popoverPosition( + final layout = _popoverLayout( anchorRect, constraints.biggest, textDirection: Directionality.of(context), @@ -81,20 +84,21 @@ class _ScheduleItemDetailsPopover extends StatelessWidget { onTap: () => Navigator.of(context).pop(), ), ), - Positioned( - left: position.left, - top: position.top, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: position.width, - maxWidth: position.width, - ), - child: _ScheduleItemDetailsPopoverCard( - item: item, - itemColor: itemColor, - surfaceColors: surfaceColors, - arrowSide: position.arrowSide, - arrowAlignment: position.arrowAlignment, + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _PopoverPositionDelegate(layout), + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: layout.width, + maxWidth: layout.width, + ), + child: _ScheduleItemDetailsPopoverCard( + item: item, + itemColor: itemColor, + surfaceColors: surfaceColors, + arrowSide: layout.arrowSide, + arrowAlignment: layout.arrowAlignment, + ), ), ), ), @@ -240,7 +244,11 @@ Rect? _globalRectFor(BuildContext context) { return renderObject.localToGlobal(Offset.zero) & renderObject.size; } -_PopoverPosition _popoverPosition( +Rect _globalRectForPoint(Offset point) { + return Rect.fromCenter(center: point, width: 1, height: 1); +} + +_PopoverLayout _popoverLayout( Rect? anchor, Size viewport, { required TextDirection textDirection, @@ -258,11 +266,9 @@ _PopoverPosition _popoverPosition( final maxLeft = math.max(margin, viewport.width - width - margin); if (anchor == null) { - return _PopoverPosition( + return _PopoverLayout( + anchor: null, left: ((viewport.width - width) / 2).clamp(margin, maxLeft).toDouble(), - top: ((viewport.height - estimatedHeight) / 2) - .clamp(margin, math.max(margin, viewport.height - margin)) - .toDouble(), width: width, arrowSide: BusyMaxPopoverArrowSide.top, arrowAlignment: 0.5, @@ -274,17 +280,14 @@ _PopoverPosition _popoverPosition( : anchor.left; final left = preferredLeft.clamp(margin, maxLeft).toDouble(); final below = anchor.bottom + gap; - final above = anchor.top - estimatedHeight - gap; - final maxTop = math.max(margin, viewport.height - estimatedHeight - margin); final showBelow = below + estimatedHeight <= viewport.height - margin; - final top = (showBelow ? below : above).clamp(margin, maxTop).toDouble(); final arrowAlignment = ((anchor.center.dx - left) / width) .clamp(0.08, 0.92) .toDouble(); - return _PopoverPosition( + return _PopoverLayout( + anchor: anchor, left: left, - top: top, width: width, arrowSide: showBelow ? BusyMaxPopoverArrowSide.top @@ -293,22 +296,67 @@ _PopoverPosition _popoverPosition( ); } -class _PopoverPosition { - const _PopoverPosition({ +class _PopoverLayout { + const _PopoverLayout({ + required this.anchor, required this.left, - required this.top, required this.width, required this.arrowSide, required this.arrowAlignment, }); + final Rect? anchor; final double left; - final double top; final double width; final BusyMaxPopoverArrowSide arrowSide; final double arrowAlignment; } +class _PopoverPositionDelegate extends SingleChildLayoutDelegate { + const _PopoverPositionDelegate(this.layout); + + final _PopoverLayout layout; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + const margin = BusyMaxSpacing.md; + return BoxConstraints( + minWidth: layout.width, + maxWidth: layout.width, + maxHeight: math.max(0, constraints.maxHeight - margin * 2), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + const margin = BusyMaxSpacing.md; + const gap = BusyMaxSpacing.xs; + final maxTop = math.max(margin, size.height - childSize.height - margin); + final anchor = layout.anchor; + + if (anchor == null) { + final centered = (size.height - childSize.height) / 2; + return Offset(layout.left, centered.clamp(margin, maxTop).toDouble()); + } + + final preferredTop = switch (layout.arrowSide) { + BusyMaxPopoverArrowSide.top => anchor.bottom + gap, + BusyMaxPopoverArrowSide.bottom => anchor.top - childSize.height - gap, + }; + return Offset(layout.left, preferredTop.clamp(margin, maxTop).toDouble()); + } + + @override + bool shouldRelayout(covariant _PopoverPositionDelegate oldDelegate) { + final old = oldDelegate.layout; + return layout.anchor != old.anchor || + layout.left != old.left || + layout.width != old.width || + layout.arrowSide != old.arrowSide || + layout.arrowAlignment != old.arrowAlignment; + } +} + class _ScheduleItemDetails extends StatelessWidget { const _ScheduleItemDetails({required this.item}); @@ -330,7 +378,7 @@ class _ScheduleItemDetails extends StatelessWidget { if (_accountLabel(item) case final account? when account.isNotEmpty) _ScheduleDetailRow(icon: Icons.person_outline, text: account), if (item is CalendarScheduleItem) - ..._eventDetails(item as CalendarScheduleItem), + ..._eventDetails(context, item as CalendarScheduleItem), if (item is TaskScheduleItem) ..._taskDetails(context, item as TaskScheduleItem), ]; @@ -427,12 +475,24 @@ class _ScheduleDetailRichRow extends StatelessWidget { } } -List _eventDetails(CalendarScheduleItem item) { +List _eventDetails(BuildContext context, CalendarScheduleItem item) { final location = item.location?.trim(); final description = item.description?.trim(); return [ if (location != null && location.isNotEmpty) _ScheduleDetailRow(icon: Icons.place_outlined, text: location), + if (item.reminderMinutesBeforeStart.isNotEmpty) + _ScheduleDetailRow( + icon: Icons.notifications_outlined, + text: + '${context.l10n.reminder}: ' + '${item.reminderMinutesBeforeStart.map(_reminderBeforeLabel).join(', ')}', + ), + if (item.categories.isNotEmpty) + _ScheduleDetailRow( + icon: Icons.sell_outlined, + text: '${context.l10n.categories}: ${item.categories.join(', ')}', + ), if (item.descriptionHtml != null && isHtmlContentType(item.descriptionContentType)) _ScheduleDetailRichRow( @@ -452,11 +512,36 @@ List _taskDetails(BuildContext context, TaskScheduleItem item) { icon: item.completed ? YaruIcons.checkmark : Icons.radio_button_unchecked, text: item.completed ? context.l10n.completed : context.l10n.openStatus, ), + if (item.reminder != null) + _ScheduleDetailRow( + icon: Icons.notifications_outlined, + text: + '${context.l10n.reminder}: ' + '${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).add_jm().format(item.reminder!)}', + ), + if (item.categories.isNotEmpty) + _ScheduleDetailRow( + icon: Icons.sell_outlined, + text: '${context.l10n.categories}: ${item.categories.join(', ')}', + ), if (notes != null && notes.isNotEmpty) _ScheduleDetailRow(icon: Icons.notes, text: notes), ]; } +String _reminderBeforeLabel(int minutes) { + return switch (minutes) { + 0 => 'At start', + 1 => '1 minute before', + < 60 => '$minutes minutes before', + 60 => '1 hour before', + < 1440 when minutes % 60 == 0 => '${minutes ~/ 60} hours before', + 1440 => '1 day before', + > 1440 when minutes % 1440 == 0 => '${minutes ~/ 1440} days before', + _ => '$minutes minutes before', + }; +} + String _kindLabel(BuildContext context, ScheduleItem item) { return item is CalendarScheduleItem ? context.l10n.createEventAtTime diff --git a/lib/src/features/schedule/presentation/schedule_item_selection.dart b/lib/src/features/schedule/presentation/schedule_item_selection.dart new file mode 100644 index 0000000..66f7c76 --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_item_selection.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../../../schedule/schedule_item.dart'; + +typedef ScheduleItemSelectionCallback = + void Function( + BuildContext context, + ScheduleItem item, [ + Offset? globalPosition, + ]); + +typedef ScheduleItemTapCallback = + void Function(BuildContext context, [Offset? globalPosition]); + +typedef ScheduleItemAnchorCallback = + void Function(ScheduleItem item, BuildContext context); diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 7e28aaf..149ef97 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; @@ -8,6 +10,7 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; import 'schedule_item_chip.dart'; +import 'schedule_item_selection.dart'; import 'schedule_more_popover.dart'; class ScheduleMonthView extends StatelessWidget { @@ -29,7 +32,7 @@ class ScheduleMonthView extends StatelessWidget { final int firstWeekday; final ValueChanged onDaySelected; final ValueChanged onCreateAtDay; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -139,7 +142,7 @@ class _MonthDayCell extends StatelessWidget { final List items; final VoidCallback onSelect; final VoidCallback onCreate; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -147,8 +150,6 @@ class _MonthDayCell extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final today = DateUtils.isSameDay(day, DateTime.now()); - final visible = items.take(4).toList(); - final overflow = items.length - visible.length; return Material( color: selected @@ -160,89 +161,163 @@ class _MonthDayCell extends StatelessWidget { child: InkWell( onTap: onSelect, onDoubleTap: onCreate, - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Container( + child: LayoutBuilder( + builder: (context, constraints) { + const headerHeight = 24.0; + const itemHeight = 22.0; + const moreHeight = 24.0; + const itemGap = BusyMaxSpacing.xs; + final contentHeight = math.max( + 0.0, + constraints.maxHeight - BusyMaxSpacing.xs * 2, + ); + final availableRowsHeight = math.max( + 0.0, + contentHeight - headerHeight - itemGap, + ); + final rowSlots = (availableRowsHeight / (itemHeight + itemGap)) + .floor(); + final needsOverflowRow = items.length > rowSlots; + final visibleCount = needsOverflowRow + ? math.max(0, rowSlots - 1) + : math.min(items.length, rowSlots); + final visible = items.take(visibleCount).toList(); + final overflow = items.length - visible.length; + final showOverflow = + overflow > 0 && availableRowsHeight >= moreHeight; + + if (contentHeight < headerHeight + itemGap) { + return Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xs), + child: Align( + alignment: AlignmentDirectional.topStart, + child: SizedBox( width: 26, - height: 22, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? colorScheme.primaryContainer - : today - ? colorScheme.primary - : null, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - ), - child: Text( - '${day.day}', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: selected - ? colorScheme.onPrimaryContainer - : today - ? colorScheme.onPrimary - : inCurrentMonth - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant.withValues( - alpha: 0.55, - ), - fontWeight: FontWeight.w600, + height: contentHeight, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.topStart, + child: _MonthDayNumber( + day: day, + selected: selected, + today: today, + inCurrentMonth: inCurrentMonth, ), ), ), - const Spacer(), - if (selected) - SizedBox.square( - dimension: 24, - child: YaruIconButton( - tooltip: context.l10n.create, - icon: const Icon(YaruIcons.plus, size: 16), - onPressed: onCreate, + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _MonthDayNumber( + day: day, + selected: selected, + today: today, + inCurrentMonth: inCurrentMonth, ), - ), - ], - ), - const SizedBox(height: BusyMaxSpacing.xs), - for (final item in visible) - Padding( - padding: const EdgeInsets.only(bottom: BusyMaxSpacing.xs), - child: ScheduleItemChip( - item: item, - height: 22, - compact: true, - onTap: (context) => onItemSelected(context, item), - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => - onTaskCompletionChanged(item, completed) - : null, + const Spacer(), + if (selected) + SizedBox.square( + dimension: 24, + child: YaruIconButton( + tooltip: context.l10n.create, + icon: const Icon(YaruIcons.plus, size: 16), + onPressed: onCreate, + ), + ), + ], ), - ), - if (overflow > 0) - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 6), - minimumSize: const Size(0, 24), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + const SizedBox(height: BusyMaxSpacing.xs), + for (final item in visible) + Padding( + padding: const EdgeInsets.only(bottom: BusyMaxSpacing.xs), + child: ScheduleItemChip( + item: item, + height: itemHeight, + compact: true, + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + onTaskCompletionChanged(item, completed) + : null, + ), ), - onPressed: () => showScheduleMorePopover( - context: context, - day: day, - items: items, - onItemSelected: onItemSelected, - onTaskCompletionChanged: onTaskCompletionChanged, + if (showOverflow) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 6), + minimumSize: const Size(0, moreHeight), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => showScheduleMorePopover( + context: context, + day: day, + items: items, + onItemSelected: onItemSelected, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + child: Text(context.l10n.moreItems(overflow)), + ), ), - child: Text(context.l10n.moreItems(overflow)), - ), - ), - ], - ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _MonthDayNumber extends StatelessWidget { + const _MonthDayNumber({ + required this.day, + required this.selected, + required this.today, + required this.inCurrentMonth, + }); + + final DateTime day; + final bool selected; + final bool today; + final bool inCurrentMonth; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: 26, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? colorScheme.primaryContainer + : today + ? colorScheme.primary + : null, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + ), + child: Text( + '${day.day}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: selected + ? colorScheme.onPrimaryContainer + : today + ? colorScheme.onPrimary + : inCurrentMonth + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant.withValues(alpha: 0.55), + fontWeight: FontWeight.w600, ), ), ); diff --git a/lib/src/features/schedule/presentation/schedule_more_popover.dart b/lib/src/features/schedule/presentation/schedule_more_popover.dart index b8e246d..039bbeb 100644 --- a/lib/src/features/schedule/presentation/schedule_more_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_more_popover.dart @@ -4,13 +4,13 @@ import 'package:intl/intl.dart'; import '../../../app/busymax_design.dart'; import '../../../schedule/schedule_item.dart'; import 'schedule_item_chip.dart'; +import 'schedule_item_selection.dart'; Future showScheduleMorePopover({ required BuildContext context, required DateTime day, required List items, - required void Function(BuildContext context, ScheduleItem item) - onItemSelected, + required ScheduleItemSelectionCallback onItemSelected, required void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged, }) { @@ -46,9 +46,9 @@ Future showScheduleMorePopover({ item: item, height: 34, compact: false, - onTap: (_) { + onTap: (context, [globalPosition]) { Navigator.of(context).pop(); - onItemSelected(context, item); + onItemSelected(context, item, globalPosition); }, onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index bd9ee45..f8cd27d 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -80,7 +80,7 @@ class _SourceRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return _CompactSourceRow( title: source.summary, - leading: _ColorDot( + leading: _SourceDot( seed: source.id, colorHex: calendarSourceBackgroundColorHex( provider: source.provider, @@ -424,24 +424,26 @@ class _AccountCalendarSources extends ConsumerWidget { } } -class _ColorDot extends StatelessWidget { - const _ColorDot({required this.seed, this.colorHex}); +class _SourceDot extends StatelessWidget { + const _SourceDot({this.seed, this.colorHex, this.color}); - final String seed; + final String? seed; final String? colorHex; + final Color? color; @override Widget build(BuildContext context) { - final color = + final resolvedColor = + color ?? _colorFromHex(colorHex) ?? ScheduleProjection.deterministicSourceColor( - seed, + seed ?? '', Theme.of(context).colorScheme.brightness, ); return Container( width: 10, height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), + decoration: BoxDecoration(color: resolvedColor, shape: BoxShape.circle), ); } } @@ -462,9 +464,7 @@ class _TaskListScheduleRow extends ConsumerWidget { final title = _taskListLabel(account, list); return _CompactSourceRow( title: title, - leading: Icon( - YaruIcons.task_list, - size: 14, + leading: _SourceDot( color: Theme.of(context).colorScheme.onSurfaceVariant, ), trailing: _SourceRowActions( diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index 65c142f..a9dfea7 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -5,6 +5,7 @@ import '../../../app/busymax_design.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import 'schedule_event_block.dart'; +import 'schedule_item_selection.dart'; class ScheduleTaskChip extends StatelessWidget { const ScheduleTaskChip({ @@ -21,7 +22,7 @@ class ScheduleTaskChip extends StatelessWidget { final double height; final double? width; final bool compact; - final ValueChanged? onTap; + final ScheduleItemTapCallback? onTap; final ValueChanged? onCompletionChanged; @override @@ -45,7 +46,15 @@ class ScheduleTaskChip extends StatelessWidget { ].join(' · '); final blockWidth = scheduleSafeBlockWidth(width); final blockHeight = scheduleSafeBlockHeight(height); + final horizontalPadding = compact ? 5.0 : 8.0; + final checkboxSize = compact ? 14.0 : 16.0; + final contentWidth = blockWidth == null + ? double.infinity + : blockWidth - horizontalPadding * 2; + final showContent = contentWidth >= 28; + final showCheckbox = contentWidth >= checkboxSize + BusyMaxSpacing.xs + 24; + Offset? pointerDownPosition; return Tooltip( message: '${item.title}\n$details', waitDuration: const Duration(milliseconds: 600), @@ -56,10 +65,15 @@ class ScheduleTaskChip extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - onTap: onTap == null ? null : () => onTap!(context), + onTapDown: onTap == null + ? null + : (details) => pointerDownPosition = details.globalPosition, + onTap: onTap == null + ? null + : () => onTap!(context, pointerDownPosition), child: Container( padding: EdgeInsets.symmetric( - horizontal: compact ? 5 : 8, + horizontal: horizontalPadding, vertical: compact ? 1 : 5, ), decoration: BoxDecoration( @@ -67,35 +81,40 @@ class ScheduleTaskChip extends StatelessWidget { borderRadius: BorderRadius.circular(BusyMaxRadius.sm), border: Border(left: BorderSide(color: color, width: 3)), ), - child: Row( - children: [ - SizedBox.square( - dimension: compact ? 14 : 16, - child: YaruCheckbox( - value: item.completed, - onChanged: onCompletionChanged == null - ? null - : (value) => onCompletionChanged!(value ?? false), - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + child: showContent + ? Row( children: [ - Text( - item.title, - maxLines: compact ? 1 : 2, - overflow: TextOverflow.ellipsis, - style: titleStyle, + if (showCheckbox) ...[ + SizedBox.square( + dimension: checkboxSize, + child: YaruCheckbox( + value: item.completed, + onChanged: onCompletionChanged == null + ? null + : (value) => + onCompletionChanged!(value ?? false), + ), + ), + const SizedBox(width: BusyMaxSpacing.xs), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ], + ), ), ], - ), - ), - ], - ), + ) + : const SizedBox.shrink(), ), ), ), diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index c235bbf..61d9438 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -31,6 +31,7 @@ class ScheduleToolbar extends StatelessWidget { @override Widget build(BuildContext context) { + final showPaging = mode != ScheduleViewMode.agenda; return SizedBox( height: BusyMaxSizes.toolbarHeight, child: Row( @@ -41,17 +42,19 @@ class ScheduleToolbar extends StatelessWidget { child: Text(context.l10n.today), ), const SizedBox(width: BusyMaxSpacing.sm), - YaruIconButton( - tooltip: MaterialLocalizations.of(context).previousPageTooltip, - icon: const Icon(YaruIcons.arrow_left), - onPressed: onPrevious, - ), - YaruIconButton( - tooltip: MaterialLocalizations.of(context).nextPageTooltip, - icon: const Icon(YaruIcons.arrow_right), - onPressed: onNext, - ), - const SizedBox(width: BusyMaxSpacing.sm), + if (showPaging) ...[ + YaruIconButton( + tooltip: MaterialLocalizations.of(context).previousPageTooltip, + icon: const Icon(YaruIcons.arrow_left), + onPressed: onPrevious, + ), + YaruIconButton( + tooltip: MaterialLocalizations.of(context).nextPageTooltip, + icon: const Icon(YaruIcons.arrow_right), + onPressed: onNext, + ), + const SizedBox(width: BusyMaxSpacing.sm), + ], Expanded( child: Text( _rangeLabel(context, mode, range, selectedDate), @@ -103,7 +106,7 @@ String _rangeLabel( ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(selectedDate), ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), - ScheduleViewMode.agenda => _weekRange(locale, range), + ScheduleViewMode.agenda => context.l10n.viewAgenda, ScheduleViewMode.week => _weekRange(locale, range), }; } diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 5f7f4e1..14e1c75 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -21,9 +21,11 @@ import '../../../schedule/schedule_filters.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; +import '../../../schedule/schedule_repository.dart'; import '../../../schedule/schedule_scope.dart'; import '../../../schedule/schedule_source_visibility.dart'; import '../../../schedule/schedule_view_mode.dart'; +import '../../../task_providers/task_provider.dart'; import '../../calendar/presentation/event_editor.dart'; import '../../calendar/presentation/event_editor_draft.dart'; import '../../task_lists/data/task_lists_repository.dart'; @@ -35,6 +37,7 @@ import 'schedule_create_menu.dart'; import 'schedule_day_week_view.dart'; import 'schedule_item_details_popover.dart'; import 'schedule_item_exporter.dart'; +import 'schedule_item_selection.dart'; import 'schedule_month_view.dart'; import 'schedule_sidebar.dart'; import 'schedule_toolbar.dart'; @@ -50,6 +53,11 @@ class ScheduleWorkspace extends ConsumerStatefulWidget { } class _ScheduleWorkspaceState extends ConsumerState { + static const _agendaInitialDays = 30; + static const _agendaPageDays = 30; + static const _agendaInitialTaskBucketLimit = 8; + static const _agendaTaskBucketPageSize = 8; + var _selectedDate = DateTime.now(); var _mode = ScheduleViewMode.week; late ScheduleScope _scope; @@ -63,6 +71,14 @@ class _ScheduleWorkspaceState extends ConsumerState { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(); var _latestCanShowSidebar = false; + var _latestItems = const []; + final _itemAnchorContexts = {}; + ScheduleWorkspaceCommand? _pendingAnchoredCommand; + List _pendingAnchoredSources = + const []; + var _agendaLoadedDays = _agendaInitialDays; + var _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit; + var _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit; ScheduleViewMode? _lastSettingsMode; _HeaderBarStateSnapshot? _lastHeaderBarState; @@ -138,24 +154,15 @@ class _ScheduleWorkspaceState extends ConsumerState { ) .toList(); - return FutureBuilder>( - future: ref - .watch(scheduleRepositoryProvider) - .listItems( - range: range, - filters: ScheduleFilters( - query: _searchQuery, - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: _scope != ScheduleScope.tasks, - includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, - showNoDateTasks: true, - ), - ), + return FutureBuilder<_ScheduleItemsResult>( + future: _scheduleItems( + repository: ref.watch(scheduleRepositoryProvider), + range: range, + searchHasQuery: searchHasQuery, + accountIds: accountIds.toSet(), + sourceIds: visibility.visibleCalendarSourceIds, + taskListIds: visibility.visibleTaskListIds, + ), builder: (context, snapshot) { final itemsLoading = snapshot.connectionState == ConnectionState.waiting && @@ -165,10 +172,15 @@ class _ScheduleWorkspaceState extends ConsumerState { sourcesLoading || taskListsLoading || itemsLoading; - final items = ScheduleProjection.filterByScope( - snapshot.data ?? const [], + final scopedItems = ScheduleProjection.filterByScope( + snapshot.data?.items ?? const [], _scope, ); + final items = + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _agendaItems(scopedItems, range) + : scopedItems; + _latestItems = items; final miniCalendarItemsFuture = ref .watch(scheduleRepositoryProvider) .listItems( @@ -194,7 +206,7 @@ class _ScheduleWorkspaceState extends ConsumerState { final displayMode = searchHasQuery ? ScheduleViewMode.agenda : _mode; - _consumePendingCommand(visibleSources, accounts, items); + _consumePendingCommand(visibleSources, accounts); final showFallbackHeader = _showFlutterHeaderFallback; final main = Column( children: [ @@ -230,11 +242,14 @@ class _ScheduleWorkspaceState extends ConsumerState { ? displayRange.start : _selectedDate, firstWeekday: _firstWeekday(context), + dayStartMinute: settings.scheduleDayStartMinute, + dayEndMinute: settings.scheduleDayEndMinute, hasAnySources: visibility.hasCalendarSources || visibility.hasTaskLists, items: items, onDaySelected: _setDate, + onYearDaySelected: _openDay, onMonthSelected: _setMonth, onEmptySlot: (start) => unawaited( _openCreateChoice(accounts, visibleSources, start), @@ -252,8 +267,36 @@ class _ScheduleWorkspaceState extends ConsumerState { onNewTask: () => unawaited(_openNewTask(accounts)), onPrevious: _previous, onNext: _next, - onItemSelected: (context, item) => - unawaited(_openItem(context, item, visibleSources)), + onAgendaLoadMore: + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaDays + : null, + hasMoreAgendaOverdueTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreOverdueTasks ?? false), + hasMoreAgendaNoDateTasks: + !searchHasQuery && + _mode == ScheduleViewMode.agenda && + (snapshot.data?.hasMoreNoDateTasks ?? false), + onAgendaLoadMoreOverdue: + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaOverdueTasks + : null, + onAgendaLoadMoreNoDate: + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaNoDateTasks + : null, + onItemSelected: (context, item, [globalPosition]) => + unawaited( + _openItem( + context, + item, + visibleSources, + globalPosition: globalPosition, + ), + ), + onItemAnchorAvailable: _handleItemAnchorAvailable, onTaskCompletionChanged: _setTaskCompleted, ), ), @@ -291,7 +334,7 @@ class _ScheduleWorkspaceState extends ConsumerState { selectedDate: _selectedDate, firstWeekday: firstWeekday, items: miniCalendarItems, - onDateSelected: _setDate, + onDateSelected: _openDay, onMonthSelected: _setMonth, onYearSelected: _setYear, onWeekSelected: _setWeek, @@ -380,6 +423,7 @@ class _ScheduleWorkspaceState extends ConsumerState { canRefresh: accounts.isNotEmpty, searchActive: _searchActive, sidebarVisible: sidebarVisible, + navigationVisible: _mode != ScheduleViewMode.agenda, ); if (_lastHeaderBarState == headerBarState) { return; @@ -403,6 +447,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ); unawaited(service.setTitleRange(headerBarState.titleRange)); unawaited(service.setViewMode(headerBarState.viewMode)); + unawaited(service.setNavigationVisible(headerBarState.navigationVisible)); unawaited(service.setCanRefresh(headerBarState.canRefresh)); unawaited(service.setSearchActive(headerBarState.searchActive)); unawaited(service.setSidebarVisible(headerBarState.sidebarVisible)); @@ -422,8 +467,14 @@ class _ScheduleWorkspaceState extends ConsumerState { case BusyMaxHeaderBarAction.today: _goToToday(); case BusyMaxHeaderBarAction.previous: + if (_mode == ScheduleViewMode.agenda) { + return; + } _previous(); case BusyMaxHeaderBarAction.next: + if (_mode == ScheduleViewMode.agenda) { + return; + } _next(); case BusyMaxHeaderBarAction.viewModeDay: _setMode(ScheduleViewMode.day); @@ -462,7 +513,7 @@ class _ScheduleWorkspaceState extends ConsumerState { final start = _day(DateTime.now()); return ScheduleRange( start: start, - end: start.add(const Duration(days: 30)), + end: start.add(Duration(days: _agendaLoadedDays)), ); } return switch (_mode) { @@ -476,9 +527,9 @@ class _ScheduleWorkspaceState extends ConsumerState { firstWeekday: firstWeekday, ), ScheduleViewMode.year => ScheduleRange.year(_selectedDate), - ScheduleViewMode.agenda => ScheduleRange.week( - _selectedDate, - firstWeekday: firstWeekday, + ScheduleViewMode.agenda => ScheduleRange( + start: _day(_selectedDate), + end: _day(_selectedDate).add(Duration(days: _agendaLoadedDays)), ), }; } @@ -502,6 +553,88 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } + Future<_ScheduleItemsResult> _scheduleItems({ + required ScheduleRepository repository, + required ScheduleRange range, + required bool searchHasQuery, + required Set accountIds, + required Set sourceIds, + required Set taskListIds, + }) async { + final currentItems = repository.listItems( + range: range, + filters: ScheduleFilters( + query: _searchQuery, + accountIds: accountIds, + sourceIds: sourceIds, + taskListIds: taskListIds, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: _scope != ScheduleScope.tasks, + includeTasks: _scope != ScheduleScope.events, + showCompletedTasks: true, + showNoDateTasks: searchHasQuery || _mode != ScheduleViewMode.agenda, + ), + ); + if (searchHasQuery || _mode != ScheduleViewMode.agenda) { + return _ScheduleItemsResult(items: await currentItems); + } + + final overdueTasks = repository.listOverdueTasks( + before: range.start, + limit: _agendaOverdueTaskLimit, + filters: ScheduleFilters( + accountIds: accountIds, + taskListIds: taskListIds, + taskListFilterActive: true, + includeTasks: _scope != ScheduleScope.events, + showCompletedTasks: false, + ), + ); + final noDateTasks = repository.listNoDateTasks( + limit: _agendaNoDateTaskLimit, + filters: ScheduleFilters( + accountIds: accountIds, + taskListIds: taskListIds, + taskListFilterActive: true, + includeTasks: _scope != ScheduleScope.events, + showCompletedTasks: true, + ), + ); + final datedItems = await currentItems; + final overduePage = await overdueTasks; + final noDatePage = await noDateTasks; + return _ScheduleItemsResult( + items: [...datedItems, ...overduePage.items, ...noDatePage.items], + hasMoreOverdueTasks: overduePage.hasMore, + hasMoreNoDateTasks: noDatePage.hasMore, + ); + } + + List _agendaItems( + List items, + ScheduleRange range, + ) { + final startDay = ScheduleProjection.day(range.start); + return items.where((item) { + final start = item.start; + if (start == null) { + return true; + } + if (item is CalendarScheduleItem) { + return ScheduleProjection.intersects(item, range); + } + if (item is TaskScheduleItem) { + final itemDay = ScheduleProjection.day(start); + if (itemDay.isBefore(startDay)) { + return !item.completed; + } + return start.isBefore(range.end); + } + return ScheduleProjection.intersects(item, range); + }).toList(); + } + Future> _taskListsForAccounts( List accounts, ) async { @@ -521,7 +654,26 @@ class _ScheduleWorkspaceState extends ConsumerState { _scope = ScheduleScope.all; } _selectedDate = _day(date); + if (_mode == ScheduleViewMode.agenda) { + _resetAgendaLoadedDays(); + } + }); + } + + void _openDay(DateTime date) { + setState(() { + if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { + _scope = ScheduleScope.all; + } + _selectedDate = _day(date); + _mode = ScheduleViewMode.day; + _lastSettingsMode = ScheduleViewMode.day; }); + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setScheduleViewMode(ScheduleViewMode.day), + ); } void _setWeek(DateTime weekStart) { @@ -596,6 +748,10 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } if (previousSettingsMode == null || _mode == previousSettingsMode) { + if (_mode != ScheduleViewMode.agenda && + settingsMode == ScheduleViewMode.agenda) { + _resetAgendaLoadedDays(); + } _mode = settingsMode; } } @@ -603,6 +759,9 @@ class _ScheduleWorkspaceState extends ConsumerState { void _goToToday() { setState(() { _selectedDate = _day(DateTime.now()); + if (_mode == ScheduleViewMode.agenda) { + _resetAgendaLoadedDays(); + } if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; } @@ -616,6 +775,9 @@ class _ScheduleWorkspaceState extends ConsumerState { setState(() { _mode = mode; _lastSettingsMode = mode; + if (mode == ScheduleViewMode.agenda) { + _resetAgendaLoadedDays(); + } if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; } @@ -645,11 +807,15 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _previous() { + if (_mode == ScheduleViewMode.agenda) { + return; + } setState(() { _selectedDate = switch (_mode) { ScheduleViewMode.day => _selectedDate.subtract(const Duration(days: 1)), - ScheduleViewMode.week || ScheduleViewMode.agenda => - _selectedDate.subtract(const Duration(days: 7)), + ScheduleViewMode.week => _selectedDate.subtract( + const Duration(days: 7), + ), ScheduleViewMode.month => DateTime( _selectedDate.year, _selectedDate.month - 1, @@ -660,6 +826,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate.month, _selectedDate.day, ), + ScheduleViewMode.agenda => _selectedDate, }; if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; @@ -668,11 +835,13 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _next() { + if (_mode == ScheduleViewMode.agenda) { + return; + } setState(() { _selectedDate = switch (_mode) { ScheduleViewMode.day => _selectedDate.add(const Duration(days: 1)), - ScheduleViewMode.week || - ScheduleViewMode.agenda => _selectedDate.add(const Duration(days: 7)), + ScheduleViewMode.week => _selectedDate.add(const Duration(days: 7)), ScheduleViewMode.month => DateTime( _selectedDate.year, _selectedDate.month + 1, @@ -683,6 +852,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate.month, _selectedDate.day, ), + ScheduleViewMode.agenda => _selectedDate, }; if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; @@ -690,6 +860,39 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } + void _loadMoreAgendaDays() { + if (_mode != ScheduleViewMode.agenda) { + return; + } + setState(() { + _agendaLoadedDays += _agendaPageDays; + }); + } + + void _loadMoreAgendaOverdueTasks() { + if (_mode != ScheduleViewMode.agenda) { + return; + } + setState(() { + _agendaOverdueTaskLimit += _agendaTaskBucketPageSize; + }); + } + + void _loadMoreAgendaNoDateTasks() { + if (_mode != ScheduleViewMode.agenda) { + return; + } + setState(() { + _agendaNoDateTaskLimit += _agendaTaskBucketPageSize; + }); + } + + void _resetAgendaLoadedDays() { + _agendaLoadedDays = _agendaInitialDays; + _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit; + _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit; + } + Future _openCreateChoice( List accounts, List sources, @@ -730,12 +933,14 @@ class _ScheduleWorkspaceState extends ConsumerState { Future _openItem( BuildContext anchorContext, ScheduleItem item, - List sources, - ) async { + List sources, { + Offset? globalPosition, + }) async { final action = await showScheduleItemDetailsPopover( context: context, anchorContext: anchorContext, item: item, + anchorPoint: globalPosition, ); if (!mounted || action == null) { return; @@ -761,10 +966,17 @@ class _ScheduleWorkspaceState extends ConsumerState { allDay: item.allDay, start: item.start, end: item.end, + startTimeZone: item.startTimeZone, + endTimeZone: item.endTimeZone, location: item.location, description: item.description, descriptionContentType: item.descriptionContentType, descriptionHtml: item.descriptionHtml, + reminders: _eventRemindersForEdit( + item.provider, + item.reminderMinutesBeforeStart, + ), + categories: item.categories, ), sources, ), @@ -858,6 +1070,7 @@ class _ScheduleWorkspaceState extends ConsumerState { context, initialDraft: draft, sources: sources, + categorySuggestionsByAccount: _categorySuggestionsByAccount(), headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!mounted || result == null) { @@ -898,16 +1111,33 @@ class _ScheduleWorkspaceState extends ConsumerState { initialAccountId: ref.read(activeAccountProvider), initialListId: null, initialDueUtc: due, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (draft == null) { return; } await ref .read(tasksRepositoryForAccountProvider(draft.accountId)) - .createTask( - draft.taskListId, - TaskCreateInput(title: draft.title, dueUtc: draft.dueUtc), - ); + .createTask(draft.taskListId, draft.input); + } + + Map> _categorySuggestionsByAccount() { + final byAccount = >{}; + for (final item in _latestItems) { + if (item.categories.isEmpty) { + continue; + } + byAccount + .putIfAbsent(item.accountId, () => {}) + .addAll( + item.categories.where((category) => category.trim().isNotEmpty), + ); + } + return { + for (final entry in byAccount.entries) + entry.key: entry.value.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())), + }; } Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { @@ -950,7 +1180,6 @@ class _ScheduleWorkspaceState extends ConsumerState { void _consumePendingCommand( List sources, List accounts, - List items, ) { final command = ref.watch(scheduleWorkspaceCommandProvider); if (command == null) { @@ -971,21 +1200,64 @@ class _ScheduleWorkspaceState extends ConsumerState { case ScheduleWorkspaceCommandKind.openDate: _openCommandDate(command.date); case ScheduleWorkspaceCommandKind.openCalendarEvent: - _openCommandDate(command.date); - final item = _findCommandItem(items, command); - if (item != null) { - unawaited(_openItem(context, item, sources)); - } + _queueAnchoredCommand(command, sources); case ScheduleWorkspaceCommandKind.openTask: - _openCommandDate(command.date); - final item = _findCommandItem(items, command); - if (item != null) { - unawaited(_openItem(context, item, sources)); - } + _queueAnchoredCommand(command, sources); } }); } + void _queueAnchoredCommand( + ScheduleWorkspaceCommand command, + List sources, + ) { + _pendingAnchoredCommand = command; + _pendingAnchoredSources = List.unmodifiable(sources); + _openCommandDate(command.date); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _tryOpenPendingAnchoredCommand(); + } + }); + } + + void _handleItemAnchorAvailable(ScheduleItem item, BuildContext context) { + _itemAnchorContexts[_itemAnchorKey(item)] = context; + _tryOpenPendingAnchoredCommand(); + } + + void _tryOpenPendingAnchoredCommand() { + if (!mounted) { + return; + } + final command = _pendingAnchoredCommand; + if (command == null) { + return; + } + final item = _findLatestCommandItem(command); + if (item == null) { + return; + } + final anchorContext = _itemAnchorContexts[_itemAnchorKey(item)]; + if (anchorContext == null || !anchorContext.mounted) { + return; + } + final sources = _pendingAnchoredSources; + _pendingAnchoredCommand = null; + _pendingAnchoredSources = const []; + unawaited(_openItem(anchorContext, item, sources)); + } + + ScheduleItem? _findLatestCommandItem(ScheduleWorkspaceCommand command) { + return switch (command.kind) { + ScheduleWorkspaceCommandKind.openCalendarEvent => + _findCommandItem(_latestItems, command), + ScheduleWorkspaceCommandKind.openTask => + _findCommandItem(_latestItems, command), + _ => null, + }; + } + void _openCommandDate(DateTime? date) { if (date == null) { return; @@ -997,6 +1269,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate = _day(date); _mode = ScheduleViewMode.agenda; _lastSettingsMode = ScheduleViewMode.agenda; + _resetAgendaLoadedDays(); }); unawaited( ref @@ -1006,6 +1279,30 @@ class _ScheduleWorkspaceState extends ConsumerState { } } +String _itemAnchorKey(ScheduleItem item) { + return '${item.kind.name}:${item.accountId}:${item.sourceId}:${item.id}'; +} + +Object? _eventRemindersForEdit(BusyProvider provider, List minutes) { + final normalized = [ + for (final value in minutes) + if (value > 0) value, + ]; + if (normalized.isEmpty) { + return null; + } + if (provider == TaskProvider.google) { + return { + 'useDefault': false, + 'overrides': [ + for (final minutes in normalized) + {'method': 'popup', 'minutes': minutes}, + ], + }; + } + return {'isReminderOn': true, 'reminderMinutesBeforeStart': normalized.first}; +} + T? _findCommandItem( List items, ScheduleWorkspaceCommand command, @@ -1069,6 +1366,7 @@ class _HeaderBarStateSnapshot { required this.canRefresh, required this.searchActive, required this.sidebarVisible, + required this.navigationVisible, }); final String titleRange; @@ -1076,6 +1374,7 @@ class _HeaderBarStateSnapshot { final bool canRefresh; final bool searchActive; final bool sidebarVisible; + final bool navigationVisible; @override bool operator ==(Object other) { @@ -1085,7 +1384,8 @@ class _HeaderBarStateSnapshot { viewMode == other.viewMode && canRefresh == other.canRefresh && searchActive == other.searchActive && - sidebarVisible == other.sidebarVisible; + sidebarVisible == other.sidebarVisible && + navigationVisible == other.navigationVisible; } @override @@ -1095,9 +1395,22 @@ class _HeaderBarStateSnapshot { canRefresh, searchActive, sidebarVisible, + navigationVisible, ); } +class _ScheduleItemsResult { + const _ScheduleItemsResult({ + required this.items, + this.hasMoreOverdueTasks = false, + this.hasMoreNoDateTasks = false, + }); + + final List items; + final bool hasMoreOverdueTasks; + final bool hasMoreNoDateTasks; +} + class _ScheduleBody extends StatelessWidget { const _ScheduleBody({ required this.isLoading, @@ -1105,9 +1418,12 @@ class _ScheduleBody extends StatelessWidget { required this.range, required this.selectedDate, required this.firstWeekday, + required this.dayStartMinute, + required this.dayEndMinute, required this.hasAnySources, required this.items, required this.onDaySelected, + required this.onYearDaySelected, required this.onMonthSelected, required this.onEmptySlot, required this.onCreateAtDay, @@ -1115,7 +1431,13 @@ class _ScheduleBody extends StatelessWidget { required this.onNewTask, required this.onPrevious, required this.onNext, + required this.onAgendaLoadMore, + required this.hasMoreAgendaOverdueTasks, + required this.hasMoreAgendaNoDateTasks, + required this.onAgendaLoadMoreOverdue, + required this.onAgendaLoadMoreNoDate, required this.onItemSelected, + required this.onItemAnchorAvailable, required this.onTaskCompletionChanged, }); @@ -1124,9 +1446,12 @@ class _ScheduleBody extends StatelessWidget { final ScheduleRange range; final DateTime selectedDate; final int firstWeekday; + final int dayStartMinute; + final int dayEndMinute; final bool hasAnySources; final List items; final ValueChanged onDaySelected; + final ValueChanged onYearDaySelected; final ValueChanged onMonthSelected; final ValueChanged onEmptySlot; final ValueChanged onCreateAtDay; @@ -1134,7 +1459,13 @@ class _ScheduleBody extends StatelessWidget { final VoidCallback onNewTask; final VoidCallback onPrevious; final VoidCallback onNext; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final VoidCallback? onAgendaLoadMore; + final bool hasMoreAgendaOverdueTasks; + final bool hasMoreAgendaNoDateTasks; + final VoidCallback? onAgendaLoadMoreOverdue; + final VoidCallback? onAgendaLoadMoreNoDate; + final ScheduleItemSelectionCallback onItemSelected; + final ScheduleItemAnchorCallback onItemAnchorAvailable; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -1151,6 +1482,8 @@ class _ScheduleBody extends StatelessWidget { range: range, selectedDate: selectedDate, daysShowed: 1, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, items: items, onDaySelected: onDaySelected, onEmptySlot: onEmptySlot, @@ -1161,6 +1494,8 @@ class _ScheduleBody extends StatelessWidget { range: range, selectedDate: selectedDate, daysShowed: 7, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, items: items, onDaySelected: onDaySelected, onEmptySlot: onEmptySlot, @@ -1188,20 +1523,22 @@ class _ScheduleBody extends StatelessWidget { selectedDate: selectedDate, items: items, firstWeekday: firstWeekday, - onDaySelected: onDaySelected, + onDaySelected: onYearDaySelected, onMonthSelected: onMonthSelected, onCreateAtDay: onCreateAtDay, ), ), - ScheduleViewMode.agenda => _HorizontalSchedulePager( - onPrevious: onPrevious, - onNext: onNext, - child: ScheduleAgendaView( - range: range, - items: items, - onItemSelected: onItemSelected, - onTaskCompletionChanged: onTaskCompletionChanged, - ), + ScheduleViewMode.agenda => ScheduleAgendaView( + range: range, + items: items, + hasMoreOverdueTasks: hasMoreAgendaOverdueTasks, + hasMoreNoDateTasks: hasMoreAgendaNoDateTasks, + onLoadMore: onAgendaLoadMore, + onLoadMoreOverdue: onAgendaLoadMoreOverdue, + onLoadMoreNoDate: onAgendaLoadMoreNoDate, + onItemSelected: onItemSelected, + onItemAnchorAvailable: onItemAnchorAvailable, + onTaskCompletionChanged: onTaskCompletionChanged, ), }; } @@ -1309,7 +1646,7 @@ String _scheduleRangeLabel( ScheduleViewMode.day => DateFormat.yMMMMEEEEd(locale).format(selectedDate), ScheduleViewMode.month => DateFormat.yMMMM(locale).format(selectedDate), ScheduleViewMode.year => DateFormat.y(locale).format(selectedDate), - ScheduleViewMode.agenda => _weekRangeLabel(locale, range), + ScheduleViewMode.agenda => context.l10n.viewAgenda, ScheduleViewMode.week => _weekRangeLabel(locale, range), }; } diff --git a/lib/src/features/schedule/presentation/tray_agenda_screen.dart b/lib/src/features/schedule/presentation/tray_agenda_screen.dart deleted file mode 100644 index d22701a..0000000 --- a/lib/src/features/schedule/presentation/tray_agenda_screen.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_about_dialog.dart'; -import '../../../app/busymax_design.dart'; -import '../../../app/busymax_yaru_theme.dart'; -import '../../../features/task_lists/data/task_lists_repository.dart'; -import '../../../l10n/l10n.dart'; -import '../../../schedule/schedule_commands.dart'; -import '../../../schedule/schedule_filters.dart'; -import '../../../schedule/schedule_item.dart'; -import '../../../schedule/schedule_projection.dart'; -import '../../../schedule/schedule_range.dart'; -import '../../../schedule/schedule_source_visibility.dart'; -import '../../../platform/linux_header_bar_service.dart'; -import '../../tasks/data/tasks_repository.dart'; -import 'schedule_agenda_view.dart'; - -final _trayAgendaDataProvider = FutureProvider.autoDispose<_TrayAgendaData>(( - ref, -) async { - final now = DateTime.now(); - final generatedAt = DateTime(now.year, now.month, now.day); - final accounts = await ref - .read(accountsRepositoryProvider) - .listSignedInAccounts(); - if (accounts.isEmpty) { - return _TrayAgendaData( - range: ScheduleRange( - start: generatedAt, - end: generatedAt.add(const Duration(days: 7)), - ), - items: const [], - hasSignedInAccounts: false, - hasSources: false, - ); - } - - final accountIds = accounts.map((account) => account.id).toList(); - final calendarSources = await ref - .read(calendarRepositoryProvider) - .listVisibleSources(accountIds); - 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.hasCalendarSources || visibility.hasTaskLists; - final range = ScheduleRange( - start: generatedAt, - end: generatedAt.add(const Duration(days: 7)), - ); - if (!hasSources) { - return _TrayAgendaData( - range: range, - items: const [], - hasSignedInAccounts: true, - hasSources: false, - ); - } - - final items = await ref - .read(scheduleRepositoryProvider) - .listItems( - range: range, - filters: ScheduleFilters( - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: true, - includeTasks: true, - showCompletedTasks: false, - showNoDateTasks: false, - ), - ); - - return _TrayAgendaData( - range: range, - items: items, - hasSignedInAccounts: true, - hasSources: true, - ); -}); - -class TrayAgendaScreen extends ConsumerStatefulWidget { - const TrayAgendaScreen({super.key}); - - @override - ConsumerState createState() => _TrayAgendaScreenState(); -} - -class _TrayAgendaScreenState extends ConsumerState { - StreamSubscription? _headerBarActions; - var _commandSequence = 0; - - @override - void initState() { - super.initState(); - _initializeHeaderBar(); - } - - @override - void dispose() { - unawaited(_headerBarActions?.cancel()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final data = ref.watch(_trayAgendaDataProvider); - final colors = BusyMaxSurfaceColors.of(context); - _updateHeaderBar(context); - - return Scaffold( - backgroundColor: colors.view, - body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 430), - child: data.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stackTrace) => BusyMaxEmptyState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaError, - message: error.toString(), - ), - data: (agenda) { - if (!agenda.hasSignedInAccounts) { - return BusyMaxEmptyState( - icon: Icons.login, - title: context.l10n.trayAgendaSignInRequired, - ); - } - if (!agenda.hasSources) { - return BusyMaxEmptyState( - icon: Icons.event_busy_outlined, - title: context.l10n.trayAgendaNoSources, - ); - } - if (agenda.items.isEmpty) { - return BusyMaxEmptyState( - icon: Icons.event_available, - title: context.l10n.noEventsOrTasks, - ); - } - return ScheduleAgendaView( - range: agenda.range, - items: agenda.items, - onItemSelected: (_, item) => _openScheduleItem(item), - onTaskCompletionChanged: _setTaskCompleted, - ); - }, - ), - ), - ), - ), - ); - } - - Future _initializeHeaderBar() async { - final service = ref.read(linuxHeaderBarServiceProvider); - _headerBarActions = service.actions.listen(_handleHeaderBarAction); - await service.initialize(); - if (mounted) { - _updateHeaderBar(context); - } - } - - void _updateHeaderBar(BuildContext context) { - final title = context.l10n.viewAgenda; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - final service = ref.read(linuxHeaderBarServiceProvider); - unawaited(() async { - await service.initialize(); - await service.setScheduleControlsVisible(false); - await service.setBackVisible(true); - await service.setOnboardingControls( - visible: false, - canGoBack: false, - canContinue: false, - backLabel: '', - continueLabel: '', - ); - await service.setTitleRange(title); - await service.setCanRefresh(false); - await service.setCanCreate(false); - await service.setSearchActive(false); - await service.setSidebarVisible(false); - }()); - }); - } - - void _handleHeaderBarAction(BusyMaxHeaderBarAction action) { - if (action == BusyMaxHeaderBarAction.back) { - context.go('/schedule'); - return; - } - if (action == BusyMaxHeaderBarAction.settings) { - context.go('/settings'); - return; - } - if (action == BusyMaxHeaderBarAction.aboutBusyMax) { - unawaited( - showBusyMaxAboutDialog( - context, - headerBarService: ref.read(linuxHeaderBarServiceProvider), - ), - ); - } - } - - void _openScheduleItem(ScheduleItem item) { - final date = item.start == null - ? DateTime.now() - : ScheduleProjection.day(item.start!); - final kind = item is TaskScheduleItem - ? ScheduleWorkspaceCommandKind.openTask - : ScheduleWorkspaceCommandKind.openCalendarEvent; - ref - .read(scheduleWorkspaceCommandProvider.notifier) - .state = ScheduleWorkspaceCommand( - kind, - ++_commandSequence, - date: date, - accountId: item.accountId, - sourceId: item.sourceId, - itemId: item.id, - ); - context.go('/schedule'); - } - - Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { - final fields = { - 'status': completed ? 'completed' : 'needsAction', - 'completed': completed ? DateTime.now().toUtc().toIso8601String() : null, - }; - await ref - .read(tasksRepositoryForAccountProvider(item.accountId)) - .patchTask(item.sourceId, item.id, TaskPatchInput(fields)); - ref.invalidate(_trayAgendaDataProvider); - } -} - -class _TrayAgendaData { - const _TrayAgendaData({ - required this.range, - required this.items, - required this.hasSignedInAccounts, - required this.hasSources, - }); - - final ScheduleRange range; - final List items; - final bool hasSignedInAccounts; - final bool hasSources; -} diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 2884f97..19df789 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -78,6 +78,29 @@ class _SettingsScreenState extends ConsumerState { onDeleteLocalData: (accountId) => _deleteLocalData(context, ref, accountId), ), + SettingsPage.schedule => BusyMaxGroupedList( + title: l10n.scheduleDisplaySettings, + description: l10n.scheduleDisplayHoursDescription, + filled: true, + children: [ + BusyMaxComboRow( + title: l10n.scheduleDayStartsAt, + leading: const Icon(YaruIcons.calendar_day), + values: _scheduleDayStartValues(settings), + selected: settings.scheduleDayStartMinute, + labelFor: (value) => _timeOfDayLabel(context, value), + onSelected: settingsController.setScheduleDayStartMinute, + ), + BusyMaxComboRow( + title: l10n.scheduleDayEndsAt, + leading: const Icon(YaruIcons.clock), + values: _scheduleDayEndValues(settings), + selected: settings.scheduleDayEndMinute, + labelFor: (value) => _timeOfDayLabel(context, value), + onSelected: settingsController.setScheduleDayEndMinute, + ), + ], + ), SettingsPage.system => BusyMaxGroupedList( title: l10n.themeSystem, filled: true, @@ -520,10 +543,18 @@ class _SettingsFallbackHeader extends StatelessWidget { } } -enum SettingsPage { accounts, system, notifications, privacy, diagnostics } +enum SettingsPage { + accounts, + schedule, + system, + notifications, + privacy, + diagnostics, +} SettingsPage settingsPageFromRouteValue(String? value) { return switch (value) { + 'schedule' => SettingsPage.schedule, 'system' => SettingsPage.system, 'notifications' => SettingsPage.notifications, 'privacy' => SettingsPage.privacy, @@ -538,6 +569,7 @@ String _settingsPageLabel(BuildContext context, SettingsPage page) { final l10n = context.l10n; return switch (page) { SettingsPage.accounts => l10n.accounts, + SettingsPage.schedule => l10n.scheduleSettings, SettingsPage.system => l10n.themeSystem, SettingsPage.notifications => l10n.notifications, SettingsPage.privacy => l10n.privacy, @@ -548,6 +580,7 @@ String _settingsPageLabel(BuildContext context, SettingsPage page) { IconData _settingsPageIcon(SettingsPage page) { return switch (page) { SettingsPage.accounts => YaruIcons.user, + SettingsPage.schedule => YaruIcons.calendar_day, SettingsPage.system => YaruIcons.desktop, SettingsPage.notifications => YaruIcons.bell, SettingsPage.privacy => YaruIcons.shield_warning, @@ -555,6 +588,31 @@ IconData _settingsPageIcon(SettingsPage page) { }; } +List _scheduleDayStartValues(AppSettings settings) { + return [ + for (var minute = 0; minute < 24 * 60; minute += 60) + if (minute < settings.scheduleDayEndMinute) minute, + ]; +} + +List _scheduleDayEndValues(AppSettings settings) { + return [ + for (var minute = 60; minute <= 24 * 60; minute += 60) + if (minute > settings.scheduleDayStartMinute) minute, + ]; +} + +String _timeOfDayLabel(BuildContext context, int minute) { + if (minute == 24 * 60) { + return '24:00'; + } + final time = TimeOfDay(hour: minute ~/ 60, minute: minute % 60); + return MaterialLocalizations.of(context).formatTimeOfDay( + time, + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); +} + Future _taskListTitleDialog(BuildContext context) { return showBusyMaxTextPrompt( context, diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index 360732f..904b57a 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -12,11 +12,13 @@ class CalendarSyncEngine { required String accountId, DateTime Function()? nowUtc, Future Function(String summary)? onConflictBlocked, + Future Function()? onNotificationScheduleChanged, }) : _repository = CalendarRepository(database: database, now: nowUtc), _database = database, _client = client, _accountId = accountId, _onConflictBlocked = onConflictBlocked, + _onNotificationScheduleChanged = onNotificationScheduleChanged, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); final AppDatabase _database; @@ -24,6 +26,7 @@ class CalendarSyncEngine { final CloudCalendarClient _client; final String _accountId; final Future Function(String summary)? _onConflictBlocked; + final Future Function()? _onNotificationScheduleChanged; final DateTime Function() _nowUtc; BusyProvider get provider => _client.provider; @@ -50,6 +53,7 @@ class CalendarSyncEngine { database: _database, nowUtc: _nowUtc, ).rebuildUpcomingEventNotifications(_accountId); + await _onNotificationScheduleChanged?.call(); } Future incrementalSync() async { @@ -89,6 +93,7 @@ class CalendarSyncEngine { database: _database, nowUtc: _nowUtc, ).rebuildUpcomingEventNotifications(_accountId); + await _onNotificationScheduleChanged?.call(); } Future _syncCalendarRange({ diff --git a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart index c1bcf05..d793637 100644 --- a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart +++ b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart @@ -428,7 +428,7 @@ class _SidebarHeader extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final logo = Image.asset( - 'assets/branding/busymax-logo.png', + 'assets/branding/busymax-logo.svg', width: 26, height: 26, errorBuilder: (context, error, stackTrace) => diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index 179ab25..5c9a4a3 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -173,6 +173,8 @@ class TaskCreateInput { this.notes, this.status, this.dueUtc, + this.categories = const [], + this.fields = const {}, this.parentTaskId, this.previousSiblingTaskId, }); @@ -181,8 +183,25 @@ class TaskCreateInput { final String? notes; final String? status; final DateTime? dueUtc; + final List categories; + final Map fields; final String? parentTaskId; final String? previousSiblingTaskId; + + Map toFields() { + final trimmedCategories = [ + for (final category in categories) + if (category.trim().isNotEmpty) category.trim(), + ]; + return { + 'title': title, + if (notes != null) 'notes': notes, + if (status != null) 'status': status, + if (dueUtc != null) 'due': dueUtc, + if (trimmedCategories.isNotEmpty) 'categories': trimmedCategories, + ...fields, + }; + } } class TaskPatchInput { @@ -245,12 +264,14 @@ class TasksRepository { required String accountId, GoogleTasksApiClient? apiClient, void Function()? onMutationQueued, + Future Function()? onNotificationScheduleChanged, Uuid uuid = const Uuid(), DateTime Function()? nowUtc, }) : _database = database, _accountId = accountId, _apiClient = apiClient, _onMutationQueued = onMutationQueued, + _onNotificationScheduleChanged = onNotificationScheduleChanged, _uuid = uuid, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); @@ -258,6 +279,7 @@ class TasksRepository { final String _accountId; final GoogleTasksApiClient? _apiClient; final void Function()? _onMutationQueued; + final Future Function()? _onNotificationScheduleChanged; final Uuid _uuid; final DateTime Function() _nowUtc; @@ -321,40 +343,53 @@ class TasksRepository { ); } + Stream> watchCategorySuggestions() { + final query = _database.select(_database.tasks) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.pendingDelete.equals(false) & + row.categoriesJson.isNotNull(), + ); + return query.watch().map((rows) { + final categories = {}; + for (final row in rows) { + categories.addAll(_stringListFromJson(row.categoriesJson)); + } + return categories.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + }); + } + Future createTask(String taskListId, TaskCreateInput input) async { final now = _now(); final localId = 'local-task-${_uuid.v4()}'; - final due = normalizeGoogleDueDateValue(input.dueUtc); + final fields = input.toFields(); + final title = fields['title']?.toString() ?? input.title; await _database.transaction(() async { await _database.tasksDao.upsertTask( TasksCompanion.insert( accountId: _accountId, taskListId: taskListId, id: localId, - title: input.title, - notes: Value(input.notes), - status: Value(input.status ?? 'needsAction'), - dueUtc: Value(due), + title: title, + status: Value(fields['status']?.toString() ?? 'needsAction'), parent: Value(input.parentTaskId), - rawJson: jsonEncode({'id': localId, 'title': input.title}), + rawJson: jsonEncode({'id': localId, 'title': title}), localDirty: const Value(true), localCreated: const Value(true), createdLocalAtUtc: now, updatedLocalAtUtc: now, ), ); + await _patchLocalTask(taskListId, localId, fields, now); await _enqueue( operation: 'create_task', taskListId: taskListId, taskId: localId, localTempId: localId, request: { - 'body': { - 'title': input.title, - if (input.notes != null) 'notes': input.notes, - if (input.status != null) 'status': input.status, - if (input.dueUtc != null) 'due': encodeGoogleDueDate(input.dueUtc!), - }, + 'body': _remoteTaskFields(fields), if (input.parentTaskId != null) 'parent': input.parentTaskId, if (input.previousSiblingTaskId != null) 'previous': input.previousSiblingTaskId, @@ -362,6 +397,7 @@ class TasksRepository { createdAtUtc: now, ); }); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -382,10 +418,7 @@ class TasksRepository { baselineRawJson: baseline?.rawJson, createdAtUtc: now, ); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -406,10 +439,7 @@ class TasksRepository { baselineRawJson: baseline?.rawJson, createdAtUtc: now, ); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -434,10 +464,7 @@ class TasksRepository { baselineRawJson: baseline?.rawJson, createdAtUtc: now, ); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -502,6 +529,14 @@ class TasksRepository { ); } + Future _rebuildTaskNotifications() async { + await NotificationScheduleService( + database: _database, + nowUtc: _nowUtc, + ).rebuildUpcomingTaskNotifications(_accountId); + await _onNotificationScheduleChanged?.call(); + } + Future _patchLocalTask( String taskListId, String taskId, @@ -746,6 +781,25 @@ Object? _remoteDueValue(Object value) { return '${normalized}T00:00:00.000Z'; } +List _stringListFromJson(String? value) { + if (value == null || value.isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(value); + if (decoded is List) { + return [ + for (final item in decoded) + if (item != null && item.toString().trim().isNotEmpty) + item.toString().trim(), + ]; + } + } on FormatException { + return const []; + } + return const []; +} + TasksCompanion taskFromDto( String accountId, String taskListId, diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 7b666bd..50d6734 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -15,12 +15,12 @@ class NativeDateTimePicker { static const _channel = MethodChannel(nativeDateTimePickerChannelName); - Future pickDate({ + Future pickDate({ required String title, required String? initialDate, required String cancelLabel, required String okLabel, - }) { + }) async { return _invoke('pickDate', { 'title': title, 'initialDate': initialDate, @@ -29,15 +29,28 @@ class NativeDateTimePicker { }); } - Future _invoke(String method, Map arguments) async { + Future _invoke( + String method, + Map arguments, + ) async { try { - return await _channel.invokeMethod(method, arguments); + return NativeDatePickResult( + available: true, + date: await _channel.invokeMethod(method, arguments), + ); } on MissingPluginException { - return null; + return const NativeDatePickResult(available: false); } } } +class NativeDatePickResult { + const NativeDatePickResult({required this.available, this.date}); + + final bool available; + final String? date; +} + class DesktopDateField extends StatefulWidget { const DesktopDateField({ super.key, @@ -47,6 +60,7 @@ class DesktopDateField extends StatefulWidget { this.enabled = true, this.onClear, this.emptyLabel, + this.useNativePicker = true, }); final String label; @@ -55,6 +69,7 @@ class DesktopDateField extends StatefulWidget { final bool enabled; final VoidCallback? onClear; final String? emptyLabel; + final bool useNativePicker; @override State createState() => _DesktopDateFieldState(); @@ -69,6 +84,7 @@ class DesktopDateValueRow extends StatelessWidget { this.enabled = true, this.onClear, this.emptyLabel, + this.useNativePicker = true, }); final String label; @@ -77,6 +93,7 @@ class DesktopDateValueRow extends StatelessWidget { final bool enabled; final VoidCallback? onClear; final String? emptyLabel; + final bool useNativePicker; @override Widget build(BuildContext context) { @@ -110,6 +127,17 @@ class DesktopDateValueRow extends StatelessWidget { if (!enabled) { return; } + if (!useNativePicker) { + final fallbackPicked = await showBusyMaxDateValueDialog( + context, + label: label, + initialDate: date, + ); + if (context.mounted && fallbackPicked != null) { + onChanged(fallbackPicked); + } + return; + } final localizations = MaterialLocalizations.of(context); final picked = await _nativeDateTimePicker.pickDate( title: label, @@ -117,8 +145,23 @@ class DesktopDateValueRow extends StatelessWidget { cancelLabel: localizations.cancelButtonLabel, okLabel: localizations.okButtonLabel, ); - if (context.mounted && picked != null) { - onChanged(picked); + if (!context.mounted) { + return; + } + if (picked.date != null) { + onChanged(picked.date!); + return; + } + if (picked.available) { + return; + } + final fallbackPicked = await showBusyMaxDateValueDialog( + context, + label: label, + initialDate: date, + ); + if (context.mounted && fallbackPicked != null) { + onChanged(fallbackPicked); } } } @@ -199,6 +242,17 @@ class _DesktopDateFieldState extends State { } Future _pickNativeDate(BuildContext context) async { + if (!widget.useNativePicker) { + final fallbackPicked = await showBusyMaxDateValueDialog( + context, + label: widget.label, + initialDate: widget.date, + ); + if (mounted && fallbackPicked != null) { + _applyPickedDate(fallbackPicked); + } + return; + } final localizations = MaterialLocalizations.of(context); final picked = await _nativeDateTimePicker.pickDate( title: widget.label, @@ -206,23 +260,126 @@ class _DesktopDateFieldState extends State { cancelLabel: localizations.cancelButtonLabel, okLabel: localizations.okButtonLabel, ); - if (context.mounted && picked != null) { - final pickedDate = parseDateOnly(picked); - if (!isSameDate(_controller.dateTime, pickedDate)) { - _syncingController = true; - _controller.dateTime = pickedDate; - _syncingController = false; + if (!context.mounted) { + return; + } + if (picked.date != null) { + _applyPickedDate(picked.date!); + return; + } + if (picked.available) { + return; + } + final fallbackPicked = await showBusyMaxDateValueDialog( + context, + label: widget.label, + initialDate: widget.date, + ); + if (mounted && fallbackPicked != null) { + _applyPickedDate(fallbackPicked); + } + } + + void _applyPickedDate(String picked) { + final pickedDate = parseDateOnly(picked); + if (!isSameDate(_controller.dateTime, pickedDate)) { + _syncingController = true; + _controller.dateTime = pickedDate; + _syncingController = false; + } + widget.onChanged(picked); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || isSameDate(_controller.dateTime, pickedDate)) { + return; } - widget.onChanged(picked); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || isSameDate(_controller.dateTime, pickedDate)) { - return; - } - _syncingController = true; - _controller.dateTime = pickedDate; - _syncingController = false; - }); + _syncingController = true; + _controller.dateTime = pickedDate; + _syncingController = false; + }); + } +} + +Future showBusyMaxDateValueDialog( + BuildContext context, { + required String label, + required String? initialDate, +}) { + return showDialog( + context: context, + barrierColor: Colors.transparent, + builder: (context) { + return _DesktopDateValueDialog(label: label, initialDate: initialDate); + }, + ); +} + +class _DesktopDateValueDialog extends StatefulWidget { + const _DesktopDateValueDialog({ + required this.label, + required this.initialDate, + }); + + final String label; + final String? initialDate; + + @override + State<_DesktopDateValueDialog> createState() => + _DesktopDateValueDialogState(); +} + +class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { + late final YaruDateTimeEntryController _controller; + DateTime? _selected; + + @override + void initState() { + super.initState(); + _selected = parseDateOnly(widget.initialDate) ?? _today(); + _controller = YaruDateTimeEntryController(dateTime: _selected); + } + + @override + Widget build(BuildContext context) { + return BusyMaxDialogShell( + title: widget.label, + maxWidth: 360, + actions: [ + BusyMaxPushButton.outlined( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.cancel), + ), + BusyMaxPushButton.filled( + onPressed: _selected == null ? null : _submit, + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + children: [ + _withoutInternalDateTimeEntryLabel( + context, + YaruDateTimeEntry( + controller: _controller, + includeTime: false, + firstDateTime: DateTime(1900), + lastDateTime: DateTime(2100, 12, 31), + acceptEmpty: false, + clearIconSemanticLabel: widget.label, + onChanged: (date) { + setState(() { + _selected = date; + }); + }, + ), + ), + ], + ); + } + + void _submit() { + final selected = _selected; + if (selected == null) { + return; } + Navigator.of(context).pop(encodeDateOnly(selected)); } } @@ -252,6 +409,7 @@ class DesktopTimeValueRow extends StatelessWidget { required this.onChanged, this.enabled = true, this.emptyLabel, + this.allowEmpty = true, }); final String label; @@ -259,6 +417,7 @@ class DesktopTimeValueRow extends StatelessWidget { final ValueChanged onChanged; final bool enabled; final String? emptyLabel; + final bool allowEmpty; @override Widget build(BuildContext context) { @@ -291,6 +450,7 @@ class DesktopTimeValueRow extends StatelessWidget { label: label, time: time, onChanged: onChanged, + allowEmpty: allowEmpty, ); }, ); @@ -302,11 +462,13 @@ class _DesktopTimeValueDialog extends StatefulWidget { required this.label, required this.time, required this.onChanged, + required this.allowEmpty, }); final String label; final String? time; final ValueChanged onChanged; + final bool allowEmpty; @override State<_DesktopTimeValueDialog> createState() => @@ -314,18 +476,48 @@ class _DesktopTimeValueDialog extends StatefulWidget { } class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { - late final YaruTimeEntryController _controller; + late final TextEditingController _controller; + final _focusNode = FocusNode(); TimeOfDay? _selected; + var _invalid = false; @override void initState() { super.initState(); _selected = parseTimeOfDay(widget.time); - _controller = YaruTimeEntryController(timeOfDay: _selected); + _controller = TextEditingController( + text: _selected == null ? '' : encodeTimeOfDay(_selected!), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + _controller.selection = TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + final timeEntry = _BusyMaxTimeTextEntry( + controller: _controller, + focusNode: _focusNode, + label: widget.label, + errorText: _invalid + ? MaterialLocalizations.of(context).invalidTimeLabel + : null, + onChanged: _setSelectedTimeText, + onSubmitted: (_) => _submit(), + ); return BusyMaxDialogShell( title: widget.label, maxWidth: 360, @@ -335,42 +527,44 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { child: Text(context.l10n.cancel), ), BusyMaxPushButton.filled( - onPressed: () { - widget.onChanged( - _selected == null ? null : encodeTimeOfDay(_selected!), - ); - Navigator.of(context).pop(); - }, + onPressed: !_invalid && (widget.allowEmpty || _selected != null) + ? _submit + : null, child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], - children: [ - _withoutInternalDateTimeEntryLabel( - context, - YaruTimeEntry( - controller: _controller, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: true, - clearIconSemanticLabel: widget.label, - onChanged: (time) { - _selected = time; - }, - ), - ), - ], + children: [_withoutInternalDateTimeEntryLabel(context, timeEntry)], ); } + + void _setSelectedTimeText(String value) { + final trimmed = value.trim(); + final parsed = parseTimeInput(trimmed); + setState(() { + _selected = parsed; + _invalid = trimmed.isNotEmpty && parsed == null; + }); + } + + void _submit() { + if (_invalid || (!widget.allowEmpty && _selected == null)) { + return; + } + widget.onChanged(_selected == null ? null : encodeTimeOfDay(_selected!)); + Navigator.of(context).pop(); + } } class _DesktopTimeFieldState extends State { - late final YaruTimeEntryController _controller; + late final TextEditingController _controller; var _syncingController = false; @override void initState() { super.initState(); - _controller = YaruTimeEntryController( - timeOfDay: parseTimeOfDay(widget.time), + final time = parseTimeOfDay(widget.time); + _controller = TextEditingController( + text: time == null ? '' : encodeTimeOfDay(time), ); } @@ -379,11 +573,10 @@ class _DesktopTimeFieldState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.time != widget.time) { final nextTime = parseTimeOfDay(widget.time); - final currentTime = _controller.timeOfDay; - if (currentTime?.hour != nextTime?.hour || - currentTime?.minute != nextTime?.minute) { + final nextText = nextTime == null ? '' : encodeTimeOfDay(nextTime); + if (_controller.text != nextText) { _syncingController = true; - _controller.timeOfDay = nextTime; + _controller.text = nextText; _syncingController = false; } } @@ -391,6 +584,7 @@ class _DesktopTimeFieldState extends State { @override void dispose() { + _controller.dispose(); super.dispose(); } @@ -398,16 +592,17 @@ class _DesktopTimeFieldState extends State { Widget build(BuildContext context) { final timeEntry = _withoutInternalDateTimeEntryLabel( context, - YaruTimeEntry( + _BusyMaxTimeTextEntry( controller: _controller, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: true, - clearIconSemanticLabel: widget.label, + label: widget.label, onChanged: (time) { if (_syncingController) { return; } - widget.onChanged(time == null ? null : encodeTimeOfDay(time)); + final parsed = parseTimeInput(time); + if (time.trim().isEmpty || parsed != null) { + widget.onChanged(parsed == null ? null : encodeTimeOfDay(parsed)); + } }, ), ); @@ -425,10 +620,87 @@ class _DesktopTimeFieldState extends State { } } +class _BusyMaxTimeTextEntry extends StatelessWidget { + const _BusyMaxTimeTextEntry({ + required this.controller, + required this.label, + required this.onChanged, + this.focusNode, + this.errorText, + this.onSubmitted, + }); + + final TextEditingController controller; + final FocusNode? focusNode; + final String label; + final ValueChanged onChanged; + final ValueChanged? onSubmitted; + final String? errorText; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + focusNode: focusNode, + keyboardType: TextInputType.datetime, + textInputAction: TextInputAction.done, + textAlign: TextAlign.center, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9:]')), + const _BusyMaxTimeInputFormatter(), + ], + decoration: InputDecoration( + hintText: '--:--', + labelText: label, + isDense: true, + errorText: errorText, + ), + onChanged: onChanged, + onFieldSubmitted: onSubmitted, + ); + } +} + +class _BusyMaxTimeInputFormatter extends TextInputFormatter { + const _BusyMaxTimeInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final text = _formatTimeEntryInput(newValue.text); + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } +} + +String _formatTimeEntryInput(String value) { + if (value.contains(':')) { + return value.length <= 5 ? value : value.substring(0, 5); + } + + final digits = value.length <= 4 ? value : value.substring(0, 4); + if (digits.length <= 2) { + return digits; + } + + if (digits.length == 3) { + final twoDigitHour = int.tryParse(digits.substring(0, 2)); + if (twoDigitHour != null && twoDigitHour <= 23) { + return '${digits.substring(0, 2)}:${digits.substring(2)}'; + } + return '${digits.substring(0, 1)}:${digits.substring(1)}'; + } + + return '${digits.substring(0, 2)}:${digits.substring(2)}'; +} + Widget _withoutInternalDateTimeEntryLabel(BuildContext context, Widget child) { - // YaruDateTimeEntry/YaruTimeEntry currently expose an internal label. - // BusyMax provides the row label through YaruListTile.square, so the - // internal field label is hidden here to avoid duplicate labels. + // The date/time rows already provide the visible label through + // YaruListTile.square, so field labels are hidden here to avoid duplicates. final theme = Theme.of(context); const hiddenLabelStyle = TextStyle( color: Colors.transparent, @@ -492,6 +764,11 @@ DateTime? parseDateOnly(String? date) { return DateTime.tryParse('${date.substring(0, 10)}T00:00:00'); } +DateTime _today() { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day); +} + DateTime? parseGraphLocalDateTime(String? dateTime) { if (dateTime == null || dateTime.isEmpty) { return null; @@ -508,7 +785,53 @@ TimeOfDay? parseTimeOfDay(String? time) { } final hour = int.tryParse(time.substring(0, 2)); final minute = int.tryParse(time.substring(3, 5)); - if (hour == null || minute == null || hour > 23 || minute > 59) { + if (hour == null || + minute == null || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59) { + return null; + } + return TimeOfDay(hour: hour, minute: minute); +} + +TimeOfDay? parseTimeInput(String? time) { + final trimmed = time?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + if (trimmed.contains(':')) { + final parts = trimmed.split(':'); + if (parts.length != 2 || parts.first.isEmpty || parts.last.isEmpty) { + return null; + } + return _timeOfDayFromParts(parts.first, parts.last); + } + + if (trimmed.length <= 2) { + return _timeOfDayFromParts(trimmed, '00'); + } + if (trimmed.length == 3) { + return _timeOfDayFromParts(trimmed.substring(0, 1), trimmed.substring(1)); + } + if (trimmed.length == 4) { + return _timeOfDayFromParts(trimmed.substring(0, 2), trimmed.substring(2)); + } + + return null; +} + +TimeOfDay? _timeOfDayFromParts(String hourText, String minuteText) { + final hour = int.tryParse(hourText); + final minute = int.tryParse(minuteText); + if (hour == null || + minute == null || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59) { return null; } return TimeOfDay(hour: hour, minute: minute); diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index b90847b..a8eca2c 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -1,28 +1,39 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:ubuntu_widgets/ubuntu_widgets.dart'; -import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; -import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; +import '../../../platform/linux_header_bar_service.dart'; +import '../../../task_providers/task_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../task_lists/data/task_lists_repository.dart'; +import '../data/tasks_repository.dart'; +import 'task_details_draft.dart'; +import 'task_details_editor.dart'; class NewTaskDraft { const NewTaskDraft({ - required this.title, required this.accountId, required this.taskListId, - this.dueUtc, + required this.input, }); - final String title; final String accountId; final String taskListId; - final DateTime? dueUtc; + final TaskCreateInput input; + + String get title => input.title; + DateTime? get dueUtc => input.dueUtc; + List get categories => input.categories; } +typedef TaskCategorySuggestionsStreamForAccount = + Stream> Function(String accountId); + Future showBusyMaxNewTaskDialog( BuildContext context, { required WidgetRef ref, @@ -30,42 +41,57 @@ Future showBusyMaxNewTaskDialog( required String? initialAccountId, required String? initialListId, DateTime? initialDueUtc, + LinuxHeaderBarService? headerBarService, }) { - return showDialog( - context: context, + return showBusyMaxModalEditorDialog( + context, + headerBarService: headerBarService, + maxWidth: 640, + maxHeight: 760, builder: (dialogContext) => UncontrolledProviderScope( container: ProviderScope.containerOf(context), - child: _NewTaskDialog( + child: NewTaskEditorPanel( accounts: accounts, initialAccountId: initialAccountId, initialListId: initialListId, initialDueUtc: initialDueUtc, + onSubmitted: (draft) => Navigator.of(dialogContext).pop(draft), + onCancel: () => Navigator.of(dialogContext).pop(), ), ), ); } -class _NewTaskDialog extends ConsumerStatefulWidget { - const _NewTaskDialog({ +class NewTaskEditorPanel extends ConsumerStatefulWidget { + const NewTaskEditorPanel({ + super.key, required this.accounts, + required this.onSubmitted, + required this.onCancel, this.initialAccountId, this.initialListId, this.initialDueUtc, + this.categorySuggestionsForAccount, + this.useNativeDatePicker = true, }); final List accounts; final String? initialAccountId; final String? initialListId; final DateTime? initialDueUtc; + final FutureOr Function(NewTaskDraft draft) onSubmitted; + final VoidCallback onCancel; + final TaskCategorySuggestionsStreamForAccount? categorySuggestionsForAccount; + final bool useNativeDatePicker; @override - ConsumerState<_NewTaskDialog> createState() => _NewTaskDialogState(); + ConsumerState createState() => _NewTaskEditorPanelState(); } -class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { - var _title = ''; +class _NewTaskEditorPanelState extends ConsumerState { String? _accountId; String? _taskListId; + TaskDetailsDraft? _draftSnapshot; @override void initState() { @@ -79,128 +105,191 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { @override Widget build(BuildContext context) { - final l10n = context.l10n; final accountId = _accountId ?? widget.accounts.first.id; + final account = _accountForId(accountId); + final provider = account?.provider ?? TaskProvider.google; + final capabilities = capabilitiesForProvider(provider); + final localTimeZone = ref.watch(localTimeZoneProvider); final repository = ref.watch( taskListsRepositoryForAccountProvider(accountId), ); + final categorySuggestionsStream = + widget.categorySuggestionsForAccount?.call(accountId) ?? + ref + .watch(tasksRepositoryForAccountProvider(accountId)) + .watchCategorySuggestions(); + + return StreamBuilder>( + stream: categorySuggestionsStream, + builder: (context, categorySnapshot) { + final categorySuggestions = categorySnapshot.data ?? const []; + return StreamBuilder>( + stream: repository.watchTaskLists(), + builder: (context, snapshot) { + final taskLists = snapshot.data ?? const []; + final effectiveListId = _effectiveListId(taskLists); + final editorTask = _newTaskEntity( + accountId: accountId, + taskListId: effectiveListId ?? '', + initialDueUtc: widget.initialDueUtc, + ); + final initialDraft = _initialDraftFor( + editorTask, + effectiveListId, + localTimeZone, + ); - return StreamBuilder>( - stream: repository.watchTaskLists(), - builder: (context, snapshot) { - final taskLists = snapshot.data ?? const []; - final effectiveListId = taskLists.any((list) => list.id == _taskListId) - ? _taskListId - : taskLists.isEmpty - ? null - : taskLists.first.id; - final canCreate = _title.trim().isNotEmpty && effectiveListId != null; - - return BusyMaxDialogShell( - title: l10n.newTask, - maxWidth: 460, - actions: [ - BusyMaxPushButton.outlined( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.cancel), - ), - BusyMaxPushButton.filled( - onPressed: canCreate ? () => _submit(effectiveListId) : null, - child: Text(l10n.create), - ), - ], - children: [ - ValidatedFormField( - autofocus: true, - labelText: l10n.title, - onChanged: (value) { - setState(() { - _title = value; - }); + return TaskDetailsEditor( + key: ValueKey('new-task-editor-$accountId-$effectiveListId'), + task: editorTask, + taskLists: taskLists, + capabilities: capabilities, + localTimeZone: localTimeZone, + initialDraft: initialDraft, + editorTitle: context.l10n.newTask, + saveLabel: context.l10n.create, + accountLabel: _accountEditorLabel(context, account, provider), + accountIds: [for (final account in widget.accounts) account.id], + selectedAccountId: accountId, + accountLabelFor: _accountLabel, + accountSecondaryLabelFor: _accountSecondaryLabel, + onAccountSelected: _selectAccount, + allowTaskListSelection: true, + showAdvancedActions: false, + showDeleteAction: false, + confirmTaskSwitch: false, + useNativeDatePicker: widget.useNativeDatePicker, + categorySuggestions: categorySuggestions, + canSaveDraft: (draft) => draft.taskListId.isNotEmpty, + onDraftChanged: (draft) { + _draftSnapshot = draft; }, - onEditingComplete: () => _submit(effectiveListId), - ), - BusyMaxGroupedList( - children: [ - BusyMaxComboRow( - title: l10n.account, - leading: const Icon(YaruIcons.user), - values: widget.accounts.map((account) => account.id).toList(), - selected: accountId, - labelFor: _accountLabel, - onSelected: (value) { - if (value == _accountId) { - return; - } - setState(() { - _accountId = value; - _taskListId = null; - }); - }, - ), - if (effectiveListId == null) - BusyMaxActionRow( - title: l10n.list, - leading: const Icon(Icons.list_alt_outlined), - enabled: false, - ) - else - BusyMaxComboRow( - key: ValueKey('task-list-$accountId'), - title: l10n.list, - leading: const Icon(Icons.list_alt_outlined), - values: taskLists.map((list) => list.id).toList(), - selected: effectiveListId, - labelFor: (value) => _listLabel(taskLists, value), - onSelected: (value) { - setState(() { - _taskListId = value; - }); - }, - ), - ], - ), - if (widget.initialDueUtc != null) - BusyMaxGroupedList( - title: l10n.scheduleSection, - children: [ - BusyMaxActionRow( - title: l10n.dueDate, - leading: const Icon(YaruIcons.calendar), - subtitle: MaterialLocalizations.of( - context, - ).formatFullDate(widget.initialDueUtc!), - ), - ], - ), - ], + onRefresh: () {}, + onSave: (draft, _) => + _submit(draft, capabilities, localTimeZone: localTimeZone), + onCreateSubtask: (_) {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: widget.onCancel, + ); + }, ); }, ); } + String? _effectiveListId(List taskLists) { + final snapshotListId = _draftSnapshot?.taskListId; + if (taskLists.any((list) => list.id == snapshotListId)) { + return snapshotListId; + } + if (taskLists.any((list) => list.id == _taskListId)) { + return _taskListId; + } + if (taskLists.isEmpty) { + return null; + } + return taskLists.first.id; + } + + TaskDetailsDraft? _initialDraftFor( + TaskEntity editorTask, + String? effectiveListId, + String localTimeZone, + ) { + final snapshot = _draftSnapshot; + if (snapshot != null) { + return snapshot.copyWith(taskListId: effectiveListId ?? ''); + } + return TaskDetailsDraft.fromTask( + editorTask, + localTimeZone, + ).copyWith(taskListId: effectiveListId ?? ''); + } + + TaskEntity _newTaskEntity({ + required String accountId, + required String taskListId, + required DateTime? initialDueUtc, + }) { + return TaskEntity( + accountId: accountId, + taskListId: taskListId, + id: 'new-task', + title: '', + notes: '', + status: 'needsAction', + dueUtc: initialDueUtc == null + ? null + : encodeGoogleDateOnly(initialDueUtc), + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '', + ); + } + + AccountEntity? _accountForId(String accountId) { + for (final account in widget.accounts) { + if (account.id == accountId) { + return account; + } + } + return null; + } + String _accountLabel(String accountId) { - return widget.accounts - .firstWhere((account) => account.id == accountId) - .displayLabel; + return _accountForId(accountId)?.displayLabel ?? accountId; + } + + String? _accountSecondaryLabel(String accountId) { + return _accountForId(accountId)?.secondaryLabel; + } + + String _accountEditorLabel( + BuildContext context, + AccountEntity? account, + TaskProvider provider, + ) { + final label = account?.displayLabel.trim(); + if (label != null && label.isNotEmpty) { + return label; + } + return provider.displayName; } - String _listLabel(List taskLists, String taskListId) { - return taskLists.firstWhere((list) => list.id == taskListId).title; + void _selectAccount(String value) { + if (value == _accountId) { + return; + } + final provider = _accountForId(value)?.provider ?? TaskProvider.google; + final capabilities = capabilitiesForProvider(provider); + setState(() { + _accountId = value; + _taskListId = null; + if (!capabilities.supportsCategories && _draftSnapshot != null) { + _draftSnapshot = _draftSnapshot!.copyWith(categories: const []); + } + }); } - void _submit(String? effectiveListId) { - final title = _title.trim(); + Future _submit( + TaskDetailsDraft draft, + TaskProviderCapabilities capabilities, { + required String localTimeZone, + }) async { final accountId = _accountId; - if (title.isEmpty || accountId == null || effectiveListId == null) { + if (accountId == null || + draft.taskListId.isEmpty || + draft.title.trim().isEmpty) { return; } - Navigator.of(context).pop( + await widget.onSubmitted( NewTaskDraft( - title: title, accountId: accountId, - taskListId: effectiveListId, - dueUtc: widget.initialDueUtc, + taskListId: draft.taskListId, + input: draft.toCreateInput(capabilities, localTimeZone: localTimeZone), ), ); } diff --git a/lib/src/features/tasks/presentation/task_details_draft.dart b/lib/src/features/tasks/presentation/task_details_draft.dart index 4d30ece..bd50414 100644 --- a/lib/src/features/tasks/presentation/task_details_draft.dart +++ b/lib/src/features/tasks/presentation/task_details_draft.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import '../../../core/time/provider_date_time.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../task_providers/task_provider.dart'; import '../data/tasks_repository.dart'; @@ -32,16 +33,42 @@ class TaskDetailsDraft { title: task.title, notes: task.notes ?? '', dueDate: _dateOnly(task.dueUtc), - microsoftDueTime: _timePart(task.microsoftDueDateTime), - microsoftDueTimeZone: task.microsoftDueTimeZone ?? localTimeZone, - microsoftStartDate: _datePart(task.microsoftStartDateTime), - microsoftStartTime: _timePart(task.microsoftStartDateTime), - microsoftStartTimeZone: task.microsoftStartTimeZone ?? localTimeZone, + microsoftDueTime: _providerTimePart( + task.microsoftDueDateTime, + task.microsoftDueTimeZone, + ), + microsoftDueTimeZone: _editorTimeZone( + task.microsoftDueDateTime, + task.microsoftDueTimeZone, + localTimeZone, + ), + microsoftStartDate: _providerDatePart( + task.microsoftStartDateTime, + task.microsoftStartTimeZone, + ), + microsoftStartTime: _providerTimePart( + task.microsoftStartDateTime, + task.microsoftStartTimeZone, + ), + microsoftStartTimeZone: _editorTimeZone( + task.microsoftStartDateTime, + task.microsoftStartTimeZone, + localTimeZone, + ), microsoftReminderEnabled: task.microsoftIsReminderOn ?? false, - microsoftReminderDate: _datePart(task.microsoftReminderDateTime), - microsoftReminderTime: _timePart(task.microsoftReminderDateTime), - microsoftReminderTimeZone: - task.microsoftReminderTimeZone ?? localTimeZone, + microsoftReminderDate: _providerDatePart( + task.microsoftReminderDateTime, + task.microsoftReminderTimeZone, + ), + microsoftReminderTime: _providerTimePart( + task.microsoftReminderDateTime, + task.microsoftReminderTimeZone, + ), + microsoftReminderTimeZone: _editorTimeZone( + task.microsoftReminderDateTime, + task.microsoftReminderTimeZone, + localTimeZone, + ), recurrenceJson: task.recurrenceJson, importance: _importanceValue(task.importance), categories: _categories(task.categoriesJson), @@ -117,8 +144,15 @@ class TaskDetailsDraft { fields['due'] = dueDate; } if (capabilities.supportsDueTime) { - final originalDueTime = _timePart(original.microsoftDueDateTime); - final originalDueZone = original.microsoftDueTimeZone ?? localTimeZone; + final originalDueTime = _providerTimePart( + original.microsoftDueDateTime, + original.microsoftDueTimeZone, + ); + final originalDueZone = _editorTimeZone( + original.microsoftDueDateTime, + original.microsoftDueTimeZone, + localTimeZone, + ); final dueTimeChanged = microsoftDueTime != originalDueTime; final dueZoneChanged = microsoftDueTimeZone != originalDueZone; if (dueChanged || dueTimeChanged || dueZoneChanged) { @@ -128,7 +162,7 @@ class TaskDetailsDraft { } else { fields['microsoftDueDateTime'] = _graphDateTime( date, - microsoftDueTime ?? '00:00', + microsoftDueTime, microsoftDueTimeZone ?? localTimeZone, ); } @@ -154,11 +188,21 @@ class TaskDetailsDraft { final reminderChanged = microsoftReminderEnabled != originalEnabled || microsoftReminderDate != - _datePart(original.microsoftReminderDateTime) || + _providerDatePart( + original.microsoftReminderDateTime, + original.microsoftReminderTimeZone, + ) || microsoftReminderTime != - _timePart(original.microsoftReminderDateTime) || + _providerTimePart( + original.microsoftReminderDateTime, + original.microsoftReminderTimeZone, + ) || (microsoftReminderTimeZone ?? localTimeZone) != - (original.microsoftReminderTimeZone ?? localTimeZone); + _editorTimeZone( + original.microsoftReminderDateTime, + original.microsoftReminderTimeZone, + localTimeZone, + ); if (reminderChanged) { fields['microsoftIsReminderOn'] = microsoftReminderEnabled; if (!microsoftReminderEnabled) { @@ -193,6 +237,40 @@ class TaskDetailsDraft { return fields; } + TaskCreateInput toCreateInput( + TaskProviderCapabilities capabilities, { + required String localTimeZone, + }) { + final baseline = TaskEntity( + accountId: '', + taskListId: taskListId, + id: taskId, + title: '', + notes: '', + status: 'needsAction', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '', + ); + final fields = toPatch( + baseline, + capabilities, + localTimeZone: localTimeZone, + ); + final trimmedTitle = title.trim(); + fields['title'] = trimmedTitle; + + return TaskCreateInput( + title: trimmedTitle, + notes: notes.trim().isEmpty ? null : notes, + dueUtc: dueDate == null ? null : DateTime.tryParse(dueDate!), + categories: categories, + fields: fields, + ); + } + TaskDetailsDraft copyWith({ String? taskListId, String? title, @@ -261,9 +339,9 @@ void _putDateTimePatch( required String timeZoneField, }) { final changed = - date != _datePart(originalDateTime) || - time != _timePart(originalDateTime) || - timeZone != originalTimeZone; + date != _providerDatePart(originalDateTime, originalTimeZone) || + time != _providerTimePart(originalDateTime, originalTimeZone) || + timeZone != _editorTimeZone(originalDateTime, originalTimeZone, timeZone); if (!changed) { return; } @@ -273,7 +351,7 @@ void _putDateTimePatch( } else { fields[dateTimeField] = _graphDateTime( date ?? _todayDateOnly(), - time ?? '00:00', + time, timeZone, ); } @@ -309,8 +387,46 @@ String? _timePart(String? value) { return time.substring(0, 5); } -Map _graphDateTime(String date, String time, String timeZone) { - return {'dateTime': '${date}T${_timeForGraph(time)}', 'timeZone': timeZone}; +String? _providerDatePart(String? value, String? timeZone) { + final parsed = providerDateTimeAsLocal(value, timeZone); + if (parsed == null) { + return _datePart(value); + } + return encodeGoogleDateOnly(parsed); +} + +String? _providerTimePart(String? value, String? timeZone) { + if (value == null || !value.contains('T')) { + return null; + } + final parsed = providerDateTimeAsLocal(value, timeZone); + if (parsed == null) { + return _timePart(value); + } + return '${parsed.hour.toString().padLeft(2, '0')}:' + '${parsed.minute.toString().padLeft(2, '0')}'; +} + +String _editorTimeZone( + String? value, + String? providerTimeZone, + String localTimeZone, +) { + if (providerDateTimeIsInstant(value, providerTimeZone)) { + return localTimeZone; + } + return providerTimeZone ?? localTimeZone; +} + +Map _graphDateTime( + String date, + String? time, + String timeZone, +) { + return { + 'dateTime': time == null ? date : '${date}T${_timeForGraph(time)}', + 'timeZone': timeZone, + }; } String _timeForGraph(String time) { diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 63a9313..1f599e2 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -7,7 +7,6 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; -import '../../../app/busymax_yaru_theme.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; import '../../../task_providers/task_provider.dart'; @@ -33,6 +32,23 @@ class TaskDetailsEditor extends StatefulWidget { this.onSaved, this.onTaskSwitchCancelled, this.onDirtyChanged, + this.onDraftChanged, + this.categorySuggestions = const [], + this.initialDraft, + this.editorTitle, + this.saveLabel, + this.accountIds = const [], + this.selectedAccountId, + this.accountLabelFor, + this.accountSecondaryLabelFor, + this.onAccountSelected, + this.allowTaskListSelection, + this.showAdvancedActions = true, + this.showDeleteAction = true, + this.confirmTaskSwitch = true, + this.useNativeDatePicker = true, + this.dialogBarrierColor, + this.canSaveDraft, }); final TaskEntity task; @@ -53,6 +69,23 @@ class TaskDetailsEditor extends StatefulWidget { final VoidCallback? onSaved; final ValueChanged? onTaskSwitchCancelled; final ValueChanged? onDirtyChanged; + final ValueChanged? onDraftChanged; + final List categorySuggestions; + final TaskDetailsDraft? initialDraft; + final String? editorTitle; + final String? saveLabel; + final List accountIds; + final String? selectedAccountId; + final String Function(String accountId)? accountLabelFor; + final String? Function(String accountId)? accountSecondaryLabelFor; + final ValueChanged? onAccountSelected; + final bool? allowTaskListSelection; + final bool showAdvancedActions; + final bool showDeleteAction; + final bool confirmTaskSwitch; + final bool useNativeDatePicker; + final Color? dialogBarrierColor; + final bool Function(TaskDetailsDraft draft)? canSaveDraft; @override State createState() => _TaskDetailsEditorState(); @@ -86,7 +119,7 @@ class _TaskDetailsEditorState extends State { final hasChanges = _hasDraftChanges(_draft); if (!sameKey) { - if (hasChanges) { + if (hasChanges && widget.confirmTaskSwitch) { unawaited(_confirmTaskSelectionChange(widget.task)); } else { _loadDraft(widget.task, force: true); @@ -113,8 +146,13 @@ class _TaskDetailsEditorState extends State { _draft ?? TaskDetailsDraft.fromTask(_editingTask, widget.localTimeZone); final l10n = context.l10n; final hasChanges = _hasDraftChanges(draft); - final canSave = draft.title.trim().isNotEmpty && hasChanges && !_saving; + final canSave = + draft.title.trim().isNotEmpty && + hasChanges && + !_saving && + (widget.canSaveDraft?.call(draft) ?? true); final currentList = _listTitle(draft.taskListId); + final scheduledAllDay = _isScheduledAllDay(draft); final listValue = [ currentList, widget.accountLabel, @@ -127,9 +165,9 @@ class _TaskDetailsEditorState extends State { child: Column( children: [ _TaskDetailsHeader( - title: l10n.editTask, + title: widget.editorTitle ?? l10n.editTask, cancelLabel: l10n.cancel, - saveLabel: l10n.save, + saveLabel: widget.saveLabel ?? l10n.save, saving: _saving, canSave: canSave, onCancel: _cancel, @@ -163,6 +201,11 @@ class _TaskDetailsEditorState extends State { ), ], ), + if (_hasAccountSelector) + BusyMaxGroupedList( + filled: true, + children: [_accountRow()], + ), BusyMaxGroupedList( filled: true, children: [_listRow(draft, listValue)], @@ -171,16 +214,31 @@ class _TaskDetailsEditorState extends State { title: l10n.dueGroup, filled: true, children: [ + if (_supportsScheduledTimeMode) + BusyMaxTimeModeRow( + allDay: scheduledAllDay, + onChanged: (value) => + _setScheduledAllDay(draft, value), + ), DesktopDateValueRow( label: l10n.dueDate, date: draft.dueDate, emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(dueDate: value)), - onClear: () => - _updateDraft(draft.copyWith(dueDate: null)), + useNativePicker: widget.useNativeDatePicker, + onClear: () => _updateDraft( + draft.copyWith( + dueDate: null, + microsoftDueTime: + widget.capabilities.supportsDueTime + ? null + : draft.microsoftDueTime, + ), + ), ), - if (widget.capabilities.supportsDueTime) + if (widget.capabilities.supportsDueTime && + !scheduledAllDay) DesktopTimeValueRow( label: l10n.dueTime, time: draft.microsoftDueTime, @@ -195,7 +253,7 @@ class _TaskDetailsEditorState extends State { BusyMaxGroupedList( title: l10n.startGroup, filled: true, - children: _startRows(draft), + children: _startRows(draft, scheduledAllDay), ), if (widget.capabilities.supportsReminderDateTime) BusyMaxGroupedList( @@ -241,7 +299,8 @@ class _TaskDetailsEditorState extends State { ), ], ), - if (widget.capabilities.supportsTaskHierarchy) + if (widget.showAdvancedActions && + widget.capabilities.supportsTaskHierarchy) BusyMaxGroupedList( title: l10n.advancedSection, filled: true, @@ -258,27 +317,29 @@ class _TaskDetailsEditorState extends State { ), ], ), - const SizedBox(height: BusyMaxSpacing.md), - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: l10n.deleteTask, - titleWidget: Center( - child: Text( - l10n.deleteTask, - style: _taskEditorProminentActionStyle( - context, - color: Theme.of(context).colorScheme.error, - fontWeight: FontWeight.w700, + if (widget.showDeleteAction) ...[ + const SizedBox(height: BusyMaxSpacing.md), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.deleteTask, + titleWidget: Center( + child: Text( + l10n.deleteTask, + style: _taskEditorProminentActionStyle( + context, + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w700, + ), ), ), + destructive: true, + onTap: _deleteTask, ), - destructive: true, - onTap: _deleteTask, - ), - ], - ), + ], + ), + ], const SizedBox(height: BusyMaxSpacing.lg), ], ), @@ -290,6 +351,35 @@ class _TaskDetailsEditorState extends State { ); } + bool get _hasAccountSelector { + return widget.selectedAccountId != null && + widget.accountIds.isNotEmpty && + widget.accountLabelFor != null && + widget.onAccountSelected != null; + } + + Widget _accountRow() { + final l10n = context.l10n; + final labelFor = widget.accountLabelFor!; + final secondaryLabelFor = widget.accountSecondaryLabelFor; + return BusyMaxComboRow( + title: l10n.account, + leading: const Icon(YaruIcons.user), + values: widget.accountIds, + selected: widget.selectedAccountId!, + labelFor: labelFor, + menuItemBuilder: (context, value) => _TaskEditorAccountIdentity( + label: labelFor(value), + secondaryLabel: secondaryLabelFor?.call(value), + ), + selectedBuilder: (context, value) => _TaskEditorAccountIdentity( + label: labelFor(value), + secondaryLabel: secondaryLabelFor?.call(value), + ), + onSelected: widget.onAccountSelected!, + ); + } + Widget _listRow(TaskDetailsDraft draft, String listValue) { final l10n = context.l10n; if (widget.taskLists.isEmpty) { @@ -300,13 +390,18 @@ class _TaskDetailsEditorState extends State { enabled: false, ); } - if (!widget.capabilities.supportsCrossListMove) { + final canSelectList = + widget.allowTaskListSelection ?? + widget.capabilities.supportsCrossListMove; + if (!canSelectList) { return BusyMaxActionRow( title: l10n.list, leading: const Icon(Icons.drive_file_move_outline), subtitle: listValue.isEmpty ? null : listValue, enabled: false, - tooltip: l10n.microsoftMoveUnsupported, + tooltip: widget.capabilities.supportsCrossListMove + ? null + : l10n.microsoftMoveUnsupported, ); } return BusyMaxComboRow( @@ -324,7 +419,7 @@ class _TaskDetailsEditorState extends State { ); } - List _startRows(TaskDetailsDraft draft) { + List _startRows(TaskDetailsDraft draft, bool scheduledAllDay) { final l10n = context.l10n; return [ DesktopDateValueRow( @@ -333,20 +428,72 @@ class _TaskDetailsEditorState extends State { emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartDate: value)), + useNativePicker: widget.useNativeDatePicker, onClear: () => _updateDraft( draft.copyWith(microsoftStartDate: null, microsoftStartTime: null), ), ), - DesktopTimeValueRow( - label: l10n.startTime, - time: draft.microsoftStartTime, - emptyLabel: l10n.noneValue, - onChanged: (value) => - _updateDraft(draft.copyWith(microsoftStartTime: value)), - ), + if (!scheduledAllDay) + DesktopTimeValueRow( + label: l10n.startTime, + time: draft.microsoftStartTime, + emptyLabel: l10n.noneValue, + onChanged: (value) => + _updateDraft(draft.copyWith(microsoftStartTime: value)), + ), ]; } + bool get _supportsScheduledTimeMode { + return widget.capabilities.supportsDueTime || + widget.capabilities.supportsStartDateTime; + } + + bool _isScheduledAllDay(TaskDetailsDraft draft) { + final hasDueTime = + widget.capabilities.supportsDueTime && draft.microsoftDueTime != null; + final hasStartTime = + widget.capabilities.supportsStartDateTime && + draft.microsoftStartTime != null; + return !hasDueTime && !hasStartTime; + } + + void _setScheduledAllDay(TaskDetailsDraft draft, bool allDay) { + if (allDay) { + _updateDraft( + draft.copyWith( + microsoftDueTime: widget.capabilities.supportsDueTime + ? null + : draft.microsoftDueTime, + microsoftStartTime: widget.capabilities.supportsStartDateTime + ? null + : draft.microsoftStartTime, + ), + ); + return; + } + final hasDueDate = draft.dueDate != null && draft.dueDate!.isNotEmpty; + final hasStartDate = + draft.microsoftStartDate != null && + draft.microsoftStartDate!.isNotEmpty; + final shouldDefaultDueTime = + widget.capabilities.supportsDueTime && (hasDueDate || !hasStartDate); + _updateDraft( + draft.copyWith( + dueDate: shouldDefaultDueTime + ? draft.dueDate ?? encodeGoogleDateOnly(DateTime.now()) + : draft.dueDate, + microsoftDueTime: shouldDefaultDueTime + ? draft.microsoftDueTime ?? '09:00' + : draft.microsoftDueTime, + microsoftStartTime: + widget.capabilities.supportsStartDateTime && hasStartDate + ? draft.microsoftStartTime ?? '09:00' + : draft.microsoftStartTime, + ), + ); + } + Widget _repeatRow(TaskDetailsDraft draft) { final l10n = context.l10n; final type = _recurrenceType(draft.recurrenceJson); @@ -388,45 +535,27 @@ class _TaskDetailsEditorState extends State { Widget _categoriesRow(TaskDetailsDraft draft) { final l10n = context.l10n; - return BusyMaxActionRow( + return BusyMaxCategoryEditorRow( title: l10n.categories, - 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 draft.categories) - _TaskCategoryChip( - label: category, - onDeleted: () => _removeCategory(draft, category), - ), - if (_addingCategory) - _TaskCategoryInputChip( - controller: _categoryController, - hintText: l10n.addCategory, - onSubmitted: (value) => _addCategory(draft, value), - onCancel: () { - _categoryController.clear(); - setState(() { - _addingCategory = false; - }); - }, - ) - else - _TaskAddCategoryChip( - label: l10n.addCategory, - onPressed: () { - setState(() { - _addingCategory = true; - }); - }, - ), - ], - ), - ), + addLabel: l10n.addCategory, + categories: draft.categories, + suggestions: widget.categorySuggestions, + adding: _addingCategory, + controller: _categoryController, + inputKey: const Key('task-category-input'), + onAddPressed: () { + setState(() { + _addingCategory = true; + }); + }, + onSubmitted: (value) => _addCategory(draft, value), + onCancelAdding: () { + _categoryController.clear(); + setState(() { + _addingCategory = false; + }); + }, + onDeleted: (category) => _removeCategory(draft, category), ); } @@ -497,6 +626,7 @@ class _TaskDetailsEditorState extends State { message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discard, destructive: true, + barrierColor: widget.dialogBarrierColor, ); if (!discard || !mounted) { return; @@ -518,6 +648,7 @@ class _TaskDetailsEditorState extends State { message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discard, destructive: true, + barrierColor: widget.dialogBarrierColor, ); if (!mounted) { return; @@ -536,6 +667,7 @@ class _TaskDetailsEditorState extends State { title: context.l10n.newSubtask, label: context.l10n.title, actionLabel: context.l10n.create, + barrierColor: widget.dialogBarrierColor, ); if (title == null || title.trim().isEmpty) { return; @@ -550,6 +682,7 @@ class _TaskDetailsEditorState extends State { message: context.l10n.deleteTaskConfirmation(_editingTask.title), confirmLabel: context.l10n.delete, destructive: true, + barrierColor: widget.dialogBarrierColor, ); if (confirmed) { await widget.onDelete(); @@ -561,7 +694,9 @@ class _TaskDetailsEditorState extends State { if (!force && _loadedTaskKey == taskKey) { return; } - final draft = TaskDetailsDraft.fromTask(task, widget.localTimeZone); + final draft = + widget.initialDraft ?? + TaskDetailsDraft.fromTask(task, widget.localTimeZone); _editingTask = task; _loadedTaskKey = taskKey; _draft = draft; @@ -575,6 +710,7 @@ class _TaskDetailsEditorState extends State { setState(() { _draft = draft; }); + widget.onDraftChanged?.call(draft); widget.onDirtyChanged?.call(_hasDraftChanges(draft)); } @@ -640,6 +776,7 @@ class _TaskDetailsEditorState extends State { emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderDate: value)), + useNativePicker: widget.useNativeDatePicker, onClear: () => _updateDraft(draft.copyWith(microsoftReminderDate: null)), ), @@ -710,6 +847,48 @@ Widget _taskEditorSelectedValue(BuildContext context, String value) { ); } +class _TaskEditorAccountIdentity extends StatelessWidget { + const _TaskEditorAccountIdentity({required this.label, this.secondaryLabel}); + + final String label; + final String? secondaryLabel; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final secondary = secondaryLabel?.trim(); + final primaryStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(height: 1.05); + final secondaryStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.05, + ); + return Align( + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: primaryStyle, + ), + if (secondary != null && secondary.isNotEmpty) + Text( + secondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: secondaryStyle, + ), + ], + ), + ); + } +} + TextStyle? _taskEditorProminentActionStyle( BuildContext context, { Color? color, @@ -752,178 +931,8 @@ class _TaskDetailsHeader extends StatelessWidget { } } -class _TaskCategoryChip extends StatelessWidget { - const _TaskCategoryChip({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), - Tooltip( - message: - '${MaterialLocalizations.of(context).deleteButtonTooltip} $label', - child: InkResponse( - onTap: onDeleted, - radius: BusyMaxSizes.iconMd, - child: Icon( - YaruIcons.window_close, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - ); - } -} - -class _TaskAddCategoryChip extends StatelessWidget { - const _TaskAddCategoryChip({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 _TaskCategoryInputChip extends StatelessWidget { - const _TaskCategoryInputChip({ - required this.controller, - required this.hintText, - required this.onSubmitted, - required this.onCancel, - }); - - final TextEditingController controller; - final String hintText; - final ValueChanged onSubmitted; - final VoidCallback onCancel; - - @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: TextField( - key: const Key('task-category-input'), - controller: controller, - autofocus: true, - decoration: InputDecoration.collapsed(hintText: hintText), - textInputAction: TextInputAction.done, - onSubmitted: onSubmitted, - ), - ), - InkResponse( - onTap: () => onSubmitted(controller.text), - radius: BusyMaxSizes.iconMd, - child: Icon( - YaruIcons.checkmark, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: BusyMaxSpacing.xs), - InkResponse( - onTap: onCancel, - radius: BusyMaxSizes.iconMd, - child: Icon( - YaruIcons.window_close, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } -} - -String _taskKey(TaskEntity task) => '${task.taskListId}/${task.id}'; +String _taskKey(TaskEntity task) => + '${task.accountId}/${task.taskListId}/${task.id}'; bool _taskChanged(TaskEntity oldTask, TaskEntity newTask) { return oldTask.updatedLocalAtUtc != newTask.updatedLocalAtUtc || diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 933543c..73857bc 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -14,23 +14,37 @@ import '../data/tasks_repository.dart'; import 'task_details_draft.dart'; import 'task_details_editor.dart'; +typedef TasksRepositoryForAccount = TasksRepository Function(String accountId); +typedef TaskListsRepositoryForAccount = + TaskListsRepository Function(String accountId); +typedef TaskMutationCommittedCallback = + FutureOr Function(String accountId); + class TaskDetailsPane extends ConsumerStatefulWidget { const TaskDetailsPane({ super.key, required this.accountId, required this.taskListId, required this.taskId, + this.tasksRepositoryForAccount, + this.taskListsRepositoryForAccount, + this.onTaskMutationCommitted, this.onClose, this.onTaskSwitchCancelled, this.onDirtyChanged, + this.dialogBarrierColor, }); final String accountId; final String taskListId; final String taskId; + final TasksRepositoryForAccount? tasksRepositoryForAccount; + final TaskListsRepositoryForAccount? taskListsRepositoryForAccount; + final TaskMutationCommittedCallback? onTaskMutationCommitted; final VoidCallback? onClose; final ValueChanged? onTaskSwitchCancelled; final ValueChanged? onDirtyChanged; + final Color? dialogBarrierColor; @override ConsumerState createState() => _TaskDetailsPaneState(); @@ -45,13 +59,20 @@ class _TaskDetailsPaneState extends ConsumerState { AccountEntity? _lastAccount; TaskProviderCapabilities? _lastCapabilities; String? _lastLocalTimeZone; + List _lastCategorySuggestions = const []; TasksRepository? _taskStreamRepository; String? _taskStreamAccountId; String? _taskStreamTaskListId; String? _taskStreamTaskId; Stream? _taskStream; + TasksRepository? _categorySuggestionsRepository; + Stream>? _categorySuggestionsStream; TaskListsRepository? _listsStreamRepository; Stream>? _listsStream; + String? _customTasksRepositoryAccountId; + TasksRepository? _customTasksRepository; + String? _customTaskListsRepositoryAccountId; + TaskListsRepository? _customTaskListsRepository; var _editorDirty = false; var _confirmingTaskSwitch = false; @@ -98,12 +119,8 @@ class _TaskDetailsPaneState extends ConsumerState { } Widget _buildContent(BuildContext context, WidgetRef ref) { - final repository = ref.watch( - tasksRepositoryForAccountProvider(_effectiveAccountId), - ); - final listsRepository = ref.watch( - taskListsRepositoryForAccountProvider(_effectiveAccountId), - ); + final repository = _tasksRepository(ref, _effectiveAccountId); + final listsRepository = _taskListsRepository(ref, _effectiveAccountId); final localTimeZone = ref.watch(localTimeZoneProvider); final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; final account = _accountForId(accounts, _effectiveAccountId); @@ -112,6 +129,7 @@ class _TaskDetailsPaneState extends ConsumerState { final capabilities = capabilitiesForProvider(provider); final taskStream = _watchTask(repository); final listsStream = _watchTaskLists(listsRepository); + final categorySuggestionsStream = _watchCategorySuggestions(repository); return StreamBuilder( stream: taskStream, @@ -126,6 +144,7 @@ class _TaskDetailsPaneState extends ConsumerState { capabilities: _lastCapabilities ?? capabilities, localTimeZone: _lastLocalTimeZone ?? localTimeZone, account: _lastAccount, + categorySuggestions: _lastCategorySuggestions, ); } return const SizedBox.shrink(); @@ -148,18 +167,27 @@ class _TaskDetailsPaneState extends ConsumerState { stream: listsStream, builder: (context, listsSnapshot) { final taskLists = listsSnapshot.data ?? const []; - _lastTask = task; - _lastTaskLists = taskLists; - _lastAccount = account; - _lastCapabilities = capabilities; - _lastLocalTimeZone = localTimeZone; - return _buildEditor( - repository: repository, - task: task, - taskLists: taskLists, - capabilities: capabilities, - localTimeZone: localTimeZone, - account: account, + return StreamBuilder>( + stream: categorySuggestionsStream, + builder: (context, categorySnapshot) { + final categorySuggestions = + categorySnapshot.data ?? _lastCategorySuggestions; + _lastTask = task; + _lastTaskLists = taskLists; + _lastAccount = account; + _lastCapabilities = capabilities; + _lastLocalTimeZone = localTimeZone; + _lastCategorySuggestions = categorySuggestions; + return _buildEditor( + repository: repository, + task: task, + taskLists: taskLists, + capabilities: capabilities, + localTimeZone: localTimeZone, + account: account, + categorySuggestions: categorySuggestions, + ); + }, ); }, ); @@ -173,12 +201,14 @@ class _TaskDetailsPaneState extends ConsumerState { TaskDetailsDraft draft, Map patch, ) async { + var mutated = false; if (patch.isNotEmpty) { await repository.patchTask( task.taskListId, task.id, TaskPatchInput(patch), ); + mutated = true; } if (draft.taskListId != task.taskListId) { await repository.moveTask( @@ -188,6 +218,10 @@ class _TaskDetailsPaneState extends ConsumerState { destinationTaskListId: draft.taskListId, ), ); + mutated = true; + } + if (mutated) { + await widget.onTaskMutationCommitted?.call(task.accountId); } } @@ -198,12 +232,14 @@ class _TaskDetailsPaneState extends ConsumerState { required TaskProviderCapabilities capabilities, required String localTimeZone, required AccountEntity? account, + required List categorySuggestions, }) { return TaskDetailsEditor( task: task, taskLists: taskLists, capabilities: capabilities, localTimeZone: localTimeZone, + categorySuggestions: categorySuggestions, accountLabel: _accountEditorLabel( context, account, @@ -214,31 +250,43 @@ class _TaskDetailsPaneState extends ConsumerState { }, onSave: (draft, patch) => _saveDraft(repository, task, draft, patch), onCreateSubtask: (title) { - unawaited( - repository.createTask( - task.taskListId, - TaskCreateInput(title: title, parentTaskId: task.id), - ), - ); + unawaited(_createSubtask(repository, task, title)); }, onMoveToTop: () { - unawaited( - repository.moveTask( - TaskMoveInput(sourceTaskListId: task.taskListId, taskId: task.id), - ), - ); + unawaited(_moveToTop(repository, task)); }, onDelete: () async { await repository.deleteTask(task.taskListId, task.id); + await widget.onTaskMutationCommitted?.call(task.accountId); widget.onClose?.call(); }, onCancel: () => widget.onClose?.call(), onSaved: () => widget.onClose?.call(), onTaskSwitchCancelled: widget.onTaskSwitchCancelled, onDirtyChanged: _setEditorDirty, + dialogBarrierColor: widget.dialogBarrierColor, ); } + Future _createSubtask( + TasksRepository repository, + TaskEntity task, + String title, + ) async { + await repository.createTask( + task.taskListId, + TaskCreateInput(title: title, parentTaskId: task.id), + ); + await widget.onTaskMutationCommitted?.call(task.accountId); + } + + Future _moveToTop(TasksRepository repository, TaskEntity task) async { + await repository.moveTask( + TaskMoveInput(sourceTaskListId: task.taskListId, taskId: task.id), + ); + await widget.onTaskMutationCommitted?.call(task.accountId); + } + void _setEditorDirty(bool dirty) { if (!mounted) { return; @@ -267,6 +315,32 @@ class _TaskDetailsPaneState extends ConsumerState { _effectiveTaskId = taskId; } + TasksRepository _tasksRepository(WidgetRef ref, String accountId) { + final factory = widget.tasksRepositoryForAccount; + if (factory == null) { + return ref.watch(tasksRepositoryForAccountProvider(accountId)); + } + if (_customTasksRepository == null || + _customTasksRepositoryAccountId != accountId) { + _customTasksRepositoryAccountId = accountId; + _customTasksRepository = factory(accountId); + } + return _customTasksRepository!; + } + + TaskListsRepository _taskListsRepository(WidgetRef ref, String accountId) { + final factory = widget.taskListsRepositoryForAccount; + if (factory == null) { + return ref.watch(taskListsRepositoryForAccountProvider(accountId)); + } + if (_customTaskListsRepository == null || + _customTaskListsRepositoryAccountId != accountId) { + _customTaskListsRepositoryAccountId = accountId; + _customTaskListsRepository = factory(accountId); + } + return _customTaskListsRepository!; + } + Future _confirmSelectionChange({ required String accountId, required String taskListId, @@ -331,6 +405,17 @@ class _TaskDetailsPaneState extends ConsumerState { return _listsStream!; } + Stream> _watchCategorySuggestions(TasksRepository repository) { + if (!identical(_categorySuggestionsRepository, repository) || + _categorySuggestionsStream == null) { + _categorySuggestionsRepository = repository; + _categorySuggestionsStream = repository + .watchCategorySuggestions() + .asBroadcastStream(); + } + return _categorySuggestionsStream!; + } + @override Widget build(BuildContext context) { return _buildContent(context, ref); diff --git a/lib/src/features/tasks/presentation/tasks_workspace.dart b/lib/src/features/tasks/presentation/tasks_workspace.dart index 8444ada..623fcbb 100644 --- a/lib/src/features/tasks/presentation/tasks_workspace.dart +++ b/lib/src/features/tasks/presentation/tasks_workspace.dart @@ -98,7 +98,6 @@ class _TasksWorkspaceState extends ConsumerState { onCreateTask: () => _createTaskFromWorkspace( context, ref, - showAllTasks: showAllTasks, selectedListId: selectedListId, ), onRefreshAll: showAllTasks @@ -337,7 +336,6 @@ class _TasksToolbar extends ConsumerWidget { ? () => _createTaskFromWorkspace( context, ref, - showAllTasks: true, selectedListId: selectedListId, ) : null @@ -346,7 +344,6 @@ class _TasksToolbar extends ConsumerWidget { : () => _createTaskFromWorkspace( context, ref, - showAllTasks: false, selectedListId: selectedListId, ), ), @@ -425,45 +422,26 @@ class _ToolbarTitle extends StatelessWidget { Future _createTaskFromWorkspace( BuildContext context, WidgetRef ref, { - required bool showAllTasks, required String? selectedListId, }) async { - if (showAllTasks) { - final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; - if (accounts.isEmpty) { - return; - } - final draft = await showBusyMaxNewTaskDialog( - context, - ref: ref, - accounts: accounts, - initialAccountId: ref.read(selectedAccountProvider)?.id, - initialListId: selectedListId, - ); - if (draft == null) { - return; - } - await ref - .read(tasksRepositoryForAccountProvider(draft.accountId)) - .createTask(draft.taskListId, TaskCreateInput(title: draft.title)); - return; - } - - final repository = ref.read(tasksRepositoryProvider); - final listId = selectedListId; - if (repository == null || listId == null) { + final accounts = ref.read(accountsStreamProvider).valueOrNull ?? const []; + if (accounts.isEmpty) { return; } - final title = await showBusyMaxTextPrompt( + final draft = await showBusyMaxNewTaskDialog( context, - title: context.l10n.newTask, - label: context.l10n.title, - actionLabel: context.l10n.create, + ref: ref, + accounts: accounts, + initialAccountId: ref.read(selectedAccountProvider)?.id, + initialListId: selectedListId, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); - if (title == null || title.trim().isEmpty) { + if (draft == null) { return; } - await repository.createTask(listId, TaskCreateInput(title: title.trim())); + await ref + .read(tasksRepositoryForAccountProvider(draft.accountId)) + .createTask(draft.taskListId, draft.input); } Future _refreshList(BuildContext context, SyncEngine syncEngine) async { diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 9f7da3c..6aa4504 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -67,6 +67,7 @@ CalendarEventDto microsoftCalendarEventFromJson( 'reminderMinutesBeforeStart': json['reminderMinutesBeforeStart'], }, attendeesJson: json['attendees'], + categoriesJson: json['categories'], organizerJson: json['organizer'], colorId: _firstCategory(json['categories']), colorHex: null, diff --git a/lib/src/microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart b/lib/src/microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart index 6073324..e23421d 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_google_tasks_adapter.dart @@ -286,7 +286,7 @@ class MicrosoftTodoGoogleTasksAdapter implements GoogleTasksApiClient { Map _dateOnlyToDateTime(Object value) { final text = value.toString(); final date = text.length >= 10 ? text.substring(0, 10) : text; - return {'dateTime': '${date}T00:00:00', 'timeZone': _defaultTimeZone}; + return {'dateTime': date, 'timeZone': _defaultTimeZone}; } Map _dateTimeTimeZone(String value) { diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index 49d7428..736a1d2 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -35,12 +35,15 @@ class BusyMaxTrayService { required LinuxWindowService windowService, required BusyMaxTrayLabels labels, required Future Function() onOpenAgenda, + Future Function()? onBeforeQuit, }) : _windowService = windowService, _labels = labels, - _onOpenAgenda = onOpenAgenda; + _onOpenAgenda = onOpenAgenda, + _onBeforeQuit = onBeforeQuit; final LinuxWindowService _windowService; final Future Function() _onOpenAgenda; + final Future Function()? _onBeforeQuit; BusyMaxTrayLabels _labels; StatusNotifierItemClient? _client; @@ -108,12 +111,12 @@ class BusyMaxTrayService { return _windowService.showWindow(); } - Future _showAgenda() async { - await _windowService.showWindow(); - await _onOpenAgenda(); + Future _showAgenda() { + return _onOpenAgenda(); } Future _quit() async { + await _onBeforeQuit?.call(); await stop(); await _windowService.quitApp(); } @@ -137,7 +140,7 @@ DBusMenuItem buildBusyMaxTrayMenu({ String _trayIconName() { final executableDir = File(Platform.resolvedExecutable).parent; final bundledLogo = File( - '${executableDir.path}/data/flutter_assets/assets/branding/busymax-logo.png', + '${executableDir.path}/data/flutter_assets/assets/branding/busymax-logo.svg', ); if (bundledLogo.existsSync()) { return bundledLogo.path; diff --git a/lib/src/platform/busymax_window_args.dart b/lib/src/platform/busymax_window_args.dart new file mode 100644 index 0000000..a594408 --- /dev/null +++ b/lib/src/platform/busymax_window_args.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +enum BusyMaxWindowKind { main, compactAgenda } + +class BusyMaxWindowArgs { + const BusyMaxWindowArgs({ + required this.kind, + required this.version, + this.requestedPositionX, + this.requestedPositionY, + }); + + final BusyMaxWindowKind kind; + final int version; + final double? requestedPositionX; + final double? requestedPositionY; + + static const currentVersion = 1; + + static const main = BusyMaxWindowArgs( + kind: BusyMaxWindowKind.main, + version: currentVersion, + ); + + static const compactAgenda = BusyMaxWindowArgs( + kind: BusyMaxWindowKind.compactAgenda, + version: currentVersion, + ); + + static BusyMaxWindowArgs compactAgendaAt({ + required double x, + required double y, + }) { + return BusyMaxWindowArgs( + kind: BusyMaxWindowKind.compactAgenda, + version: currentVersion, + requestedPositionX: x, + requestedPositionY: y, + ); + } + + String encode() { + return jsonEncode({ + 'app': 'BusyMax', + 'version': version, + 'kind': kind.name, + if (requestedPositionX != null && requestedPositionY != null) + 'position': {'x': requestedPositionX, 'y': requestedPositionY}, + }); + } + + static BusyMaxWindowArgs parse(String raw) { + if (raw.trim().isEmpty) { + return main; + } + + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return main; + } + if (decoded['app'] != 'BusyMax') { + return main; + } + final kind = decoded['kind']?.toString(); + if (kind == BusyMaxWindowKind.compactAgenda.name) { + final position = decoded['position']; + final x = position is Map ? position['x'] : null; + final y = position is Map ? position['y'] : null; + final parsedX = x is num ? x.toDouble() : null; + final parsedY = y is num ? y.toDouble() : null; + if (parsedX != null && + parsedY != null && + parsedX.isFinite && + parsedY.isFinite) { + return compactAgendaAt(x: parsedX, y: parsedY); + } + return compactAgenda; + } + return main; + } on Object { + return main; + } + } +} diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart new file mode 100644 index 0000000..ac322f6 --- /dev/null +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -0,0 +1,186 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/widgets.dart'; +import 'package:screen_retriever/screen_retriever.dart'; + +import 'busymax_window_args.dart'; + +const _compactAgendaWindowWidth = 420.0; +const _compactAgendaWindowHeight = 680.0; +const _compactAgendaWindowShadowMargin = 32.0; +const _compactAgendaPanelScreenGap = 6.0; + +class CompactAgendaWindowService { + const CompactAgendaWindowService(); + + Future toggle() async { + final position = await _preferredCompactAgendaPosition(); + final controller = await _findCompactAgendaWindow(); + if (controller == null) { + await _createCompactAgendaWindow(position); + return; + } + await _invokeCompactMethod( + controller, + 'busymax.compactAgenda.toggle', + position, + ); + } + + Future show() async { + final position = await _preferredCompactAgendaPosition(); + final controller = await _findCompactAgendaWindow(); + if (controller == null) { + await _createCompactAgendaWindow(position); + return; + } + await _invokeCompactMethod( + controller, + 'busymax.compactAgenda.show', + position, + ); + } + + Future hide() async { + final controller = await _findCompactAgendaWindow(); + if (controller == null) { + return; + } + await _invokeOrIgnore(controller, 'busymax.compactAgenda.hide'); + } + + Future closeIfOpen() async { + final controller = await _findCompactAgendaWindow(); + if (controller == null) { + return; + } + await _invokeOrIgnore(controller, 'busymax.compactAgenda.destroy'); + } + + Future _findCompactAgendaWindow() async { + final controllers = await WindowController.getAll(); + for (final controller in controllers) { + final args = BusyMaxWindowArgs.parse(controller.arguments); + if (args.kind == BusyMaxWindowKind.compactAgenda) { + return controller; + } + } + return null; + } + + Future _createCompactAgendaWindow(Offset position) async { + await WindowController.create( + WindowConfiguration( + arguments: BusyMaxWindowArgs.compactAgendaAt( + x: position.dx, + y: position.dy, + ).encode(), + hiddenAtLaunch: true, + ), + ); + } + + Future _invokeCompactMethod( + WindowController controller, + String method, + Offset position, + ) async { + const attempts = 12; + const retryDelay = Duration(milliseconds: 80); + for (var attempt = 0; attempt < attempts; attempt += 1) { + try { + await controller.invokeMethod( + method, + _positionMethodArguments(position), + ); + return; + } on Object { + if (attempt == attempts - 1) { + return; + } + await Future.delayed(retryDelay); + } + } + } + + Future _invokeOrIgnore( + WindowController controller, + String method, + ) async { + try { + await controller.invokeMethod(method); + } on Object { + // Window may already be closing; nothing useful to do. + } + } + + Future _preferredCompactAgendaPosition() async { + try { + final primaryDisplay = await screenRetriever.getPrimaryDisplay(); + final displays = await screenRetriever.getAllDisplays(); + final cursor = await screenRetriever.getCursorScreenPoint(); + final display = displays.firstWhere((display) { + final position = display.visiblePosition ?? Offset.zero; + final size = display.visibleSize ?? display.size; + return Rect.fromLTWH( + position.dx, + position.dy, + size.width, + size.height, + ).contains(cursor); + }, orElse: () => primaryDisplay); + return _topRightWorkAreaPosition(display); + } on Object { + try { + return _topRightWorkAreaPosition( + await screenRetriever.getPrimaryDisplay(), + ); + } on Object { + return Offset.zero; + } + } + } + + Map _positionMethodArguments(Offset position) { + return { + 'position': {'x': position.dx, 'y': position.dy}, + }; + } + + Offset _topRightWorkAreaPosition(Display display) { + final visiblePosition = display.visiblePosition ?? Offset.zero; + final visibleSize = display.visibleSize ?? display.size; + final visibleFrame = Rect.fromLTWH( + visiblePosition.dx, + visiblePosition.dy, + visibleSize.width, + visibleSize.height, + ); + final panelLeft = _clampToVisibleFrame( + visibleFrame.right - + _compactAgendaWindowWidth - + _compactAgendaPanelScreenGap, + visibleFrame.left + _compactAgendaPanelScreenGap, + visibleFrame.right - + _compactAgendaWindowWidth - + _compactAgendaPanelScreenGap, + ); + final panelTop = _clampToVisibleFrame( + visibleFrame.top + _compactAgendaPanelScreenGap, + visibleFrame.top + _compactAgendaPanelScreenGap, + visibleFrame.bottom - + _compactAgendaWindowHeight - + _compactAgendaPanelScreenGap, + ); + return Offset( + panelLeft - _compactAgendaWindowShadowMargin, + panelTop - _compactAgendaWindowShadowMargin, + ); + } + + double _clampToVisibleFrame(double value, double min, double max) { + if (max < min) { + return min; + } + return value.clamp(min, max).toDouble(); + } +} diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 8030cb4..468252c 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -124,6 +124,7 @@ class BusyMaxHeaderBarLabels { @immutable class BusyMaxHeaderBarTheme { const BusyMaxHeaderBarTheme({ + required this.windowBackgroundColor, required this.backgroundColor, required this.sidebarBackgroundColor, required this.foregroundColor, @@ -140,6 +141,7 @@ class BusyMaxHeaderBarTheme { required this.modalBarrierColor, }); + final Color windowBackgroundColor; final Color backgroundColor; final Color sidebarBackgroundColor; final Color foregroundColor; @@ -157,6 +159,7 @@ class BusyMaxHeaderBarTheme { Map toJson() { return { + 'windowBackgroundColor': busyMaxCssColor(windowBackgroundColor), 'backgroundColor': busyMaxCssColor(backgroundColor), 'sidebarBackgroundColor': busyMaxCssColor(sidebarBackgroundColor), 'foregroundColor': busyMaxCssColor(foregroundColor), @@ -178,6 +181,7 @@ class BusyMaxHeaderBarTheme { bool operator ==(Object other) { return identical(this, other) || other is BusyMaxHeaderBarTheme && + other.windowBackgroundColor == windowBackgroundColor && other.backgroundColor == backgroundColor && other.sidebarBackgroundColor == sidebarBackgroundColor && other.foregroundColor == foregroundColor && @@ -196,6 +200,7 @@ class BusyMaxHeaderBarTheme { @override int get hashCode => Object.hash( + windowBackgroundColor, backgroundColor, sidebarBackgroundColor, foregroundColor, @@ -234,6 +239,7 @@ class LinuxHeaderBarService { bool? _canCreate; bool? _searchActive; bool? _sidebarVisible; + bool? _navigationVisible; bool? _scheduleControlsVisible; bool? _backVisible; _BusyMaxOnboardingControlsState? _onboardingControls; @@ -351,6 +357,17 @@ class LinuxHeaderBarService { await _invokeIfAvailable('setSidebarVisible', value); } + Future setNavigationVisible(bool value) async { + if (!_available) { + return; + } + if (_navigationVisible == value) { + return; + } + _navigationVisible = value; + await _invokeIfAvailable('setNavigationVisible', value); + } + Future setScheduleControlsVisible(bool value) async { if (!_available) { return; diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart new file mode 100644 index 0000000..9e21d24 --- /dev/null +++ b/lib/src/platform/main_window_command_bridge.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../app/app_bootstrap.dart'; +import '../app/app_router.dart'; +import '../schedule/schedule_commands.dart'; +import 'main_window_command_client.dart'; + +class MainWindowCommandBridge extends ConsumerStatefulWidget { + const MainWindowCommandBridge({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => + _MainWindowCommandBridgeState(); +} + +class _MainWindowCommandBridgeState + extends ConsumerState { + var _sequence = 0; + + @override + void initState() { + super.initState(); + unawaited(busyMaxMainWindowChannel.setMethodCallHandler(_handleMethodCall)); + } + + @override + void dispose() { + unawaited(busyMaxMainWindowChannel.setMethodCallHandler(null)); + super.dispose(); + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'busymax.main.open': + await ref.read(linuxWindowServiceProvider).showWindow(); + return true; + case 'busymax.main.openScheduleItem': + return _openScheduleItem(call.arguments); + case 'busymax.main.newTask': + return _newTask(); + case 'busymax.main.refreshAll': + await ref.read(allAccountsSyncRunnerProvider)(); + return true; + case 'busymax.main.requestTaskSync': + return _requestTaskSync(call.arguments); + case 'busymax.main.requestCalendarSync': + return _requestCalendarSync(call.arguments); + } + + throw MissingPluginException('Not implemented: ${call.method}'); + } + + Future _openScheduleItem(Object? rawArgs) async { + if (rawArgs is! Map) { + return false; + } + final args = rawArgs.cast(); + final kind = args['kind']?.toString(); + final accountId = args['accountId']?.toString(); + final sourceId = args['sourceId']?.toString(); + final itemId = args['itemId']?.toString(); + final rawDate = args['date']?.toString(); + final date = rawDate == null ? null : DateTime.tryParse(rawDate); + + if (kind == null || + accountId == null || + sourceId == null || + itemId == null) { + return false; + } + + final commandKind = switch (kind) { + 'task' => ScheduleWorkspaceCommandKind.openTask, + 'calendarEvent' => ScheduleWorkspaceCommandKind.openCalendarEvent, + _ => null, + }; + if (commandKind == null) { + return false; + } + + await ref.read(linuxWindowServiceProvider).showWindow(); + ref + .read(scheduleWorkspaceCommandProvider.notifier) + .state = ScheduleWorkspaceCommand( + commandKind, + ++_sequence, + date: date, + accountId: accountId, + sourceId: sourceId, + itemId: itemId, + ); + ref.read(appRouterProvider).go('/schedule'); + return true; + } + + Future _newTask() async { + await ref.read(linuxWindowServiceProvider).showWindow(); + ref + .read(scheduleWorkspaceCommandProvider.notifier) + .state = ScheduleWorkspaceCommand( + ScheduleWorkspaceCommandKind.newTask, + ++_sequence, + ); + ref.read(appRouterProvider).go('/schedule'); + return true; + } + + Future _requestTaskSync(Object? rawArgs) async { + if (rawArgs is! Map) { + return false; + } + final accountId = rawArgs.cast()['accountId']?.toString(); + if (accountId == null || accountId.isEmpty) { + return false; + } + ref + .read(pendingMutationSyncRequesterForAccountProvider(accountId)) + .request(); + unawaited(ref.read(notificationSchedulerProvider).checkNow()); + return true; + } + + Future _requestCalendarSync(Object? rawArgs) async { + if (rawArgs is! Map) { + return false; + } + final accountId = rawArgs.cast()['accountId']?.toString(); + if (accountId == null || accountId.isEmpty) { + return false; + } + unawaited(_syncCalendarAccount(accountId)); + unawaited(ref.read(notificationSchedulerProvider).checkNow()); + return true; + } + + Future _syncCalendarAccount(String accountId) async { + try { + await ref + .read(calendarSyncEngineForAccountFactoryProvider)(accountId) + .incrementalSync(); + } on Object { + // The local pending operation remains queued and a later refresh/sync can + // retry it. + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/src/platform/main_window_command_client.dart b/lib/src/platform/main_window_command_client.dart new file mode 100644 index 0000000..ad24792 --- /dev/null +++ b/lib/src/platform/main_window_command_client.dart @@ -0,0 +1,57 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; + +import '../schedule/schedule_item.dart'; +import '../schedule/schedule_projection.dart'; + +const busyMaxMainWindowChannelName = 'io.busystack.busymax/main-window'; + +const busyMaxMainWindowChannel = WindowMethodChannel( + busyMaxMainWindowChannelName, + mode: ChannelMode.unidirectional, +); + +class MainWindowCommandClient { + const MainWindowCommandClient(); + + Future openMain() async { + await busyMaxMainWindowChannel.invokeMethod('busymax.main.open'); + } + + Future openScheduleItem(ScheduleItem item) async { + final date = item.start == null + ? ScheduleProjection.day(DateTime.now()) + : ScheduleProjection.day(item.start!); + await busyMaxMainWindowChannel + .invokeMethod('busymax.main.openScheduleItem', { + 'kind': item is TaskScheduleItem ? 'task' : 'calendarEvent', + 'accountId': item.accountId, + 'sourceId': item.sourceId, + 'itemId': item.id, + 'date': date.toIso8601String(), + }); + } + + Future newTask() async { + await busyMaxMainWindowChannel.invokeMethod('busymax.main.newTask'); + } + + Future refreshAll() async { + await busyMaxMainWindowChannel.invokeMethod( + 'busymax.main.refreshAll', + ); + } + + Future requestTaskSync(String accountId) async { + await busyMaxMainWindowChannel.invokeMethod( + 'busymax.main.requestTaskSync', + {'accountId': accountId}, + ); + } + + Future requestCalendarSync(String accountId) async { + await busyMaxMainWindowChannel.invokeMethod( + 'busymax.main.requestCalendarSync', + {'accountId': accountId}, + ); + } +} diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 3637bb4..2d6bb6c 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -14,6 +14,7 @@ sealed class ScheduleItem { DateTime? get start; DateTime? get end; bool get allDay; + List get categories; ScheduleItemKind get kind; } @@ -28,11 +29,15 @@ class CalendarScheduleItem implements ScheduleItem { required this.allDay, this.start, this.end, + this.startTimeZone, + this.endTimeZone, this.location, this.description, this.descriptionContentType, this.descriptionHtml, this.colorHex, + this.categories = const [], + this.reminderMinutesBeforeStart = const [], this.sourceName, this.accountDisplayName, this.accountEmail, @@ -53,6 +58,8 @@ class CalendarScheduleItem implements ScheduleItem { final DateTime? start; @override final DateTime? end; + final String? startTimeZone; + final String? endTimeZone; @override final bool allDay; final String? location; @@ -61,6 +68,9 @@ class CalendarScheduleItem implements ScheduleItem { final String? descriptionHtml; final String? colorHex; @override + final List categories; + final List reminderMinutesBeforeStart; + @override final String? sourceName; @override final String? accountDisplayName; @@ -83,6 +93,8 @@ class TaskScheduleItem implements ScheduleItem { this.start, this.end, this.notes, + this.categories = const [], + this.reminder, this.sourceName, this.accountDisplayName, this.accountEmail, @@ -107,6 +119,9 @@ class TaskScheduleItem implements ScheduleItem { final bool completed; final String? notes; @override + final List categories; + final DateTime? reminder; + @override final String? sourceName; @override final String? accountDisplayName; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 359ddeb..2efbdf3 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -5,9 +5,11 @@ import 'package:drift/drift.dart'; import '../calendar_providers/calendar_colors.dart'; import '../calendar_providers/calendar_description.dart'; import '../db/app_database.dart'; +import '../core/time/provider_date_time.dart'; import '../task_providers/task_provider.dart'; import 'schedule_filters.dart'; import 'schedule_item.dart'; +import 'schedule_projection.dart'; import 'schedule_range.dart'; import 'schedule_sorting.dart'; @@ -20,24 +22,10 @@ class ScheduleRepository { required ScheduleRange range, ScheduleFilters filters = const ScheduleFilters(), }) async { - final accountIds = await _accountIds(filters); - if (accountIds.isEmpty) { + final context = await _accountContext(filters); + if (context == null) { return const []; } - - final accounts = await (_database.select( - _database.accounts, - )..where((row) => row.id.isIn(accountIds))).get(); - final providers = { - for (final account in accounts) - account.id: TaskProviderParsing.fromStorageValue(account.provider), - }; - final accountDisplayNames = { - for (final account in accounts) account.id: account.displayName, - }; - final accountEmails = { - for (final account in accounts) account.id: account.email, - }; final searching = filters.query.trim().isNotEmpty; final items = [ @@ -46,20 +34,20 @@ class ScheduleRepository { range, filters, searching, - accountIds, - providers, - accountDisplayNames, - accountEmails, + context.accountIds, + context.providers, + context.accountDisplayNames, + context.accountEmails, ), if (filters.includeTasks) ...await _taskItems( range, filters, searching, - accountIds, - providers, - accountDisplayNames, - accountEmails, + context.accountIds, + context.providers, + context.accountDisplayNames, + context.accountEmails, ), ]; final filtered = filters.query.trim().isEmpty @@ -71,6 +59,37 @@ class ScheduleRepository { return filtered; } + Future listOverdueTasks({ + required DateTime before, + required int limit, + ScheduleFilters filters = const ScheduleFilters(), + }) async { + return _limitedTaskBucket( + limit: limit, + filters: filters, + databaseFilter: _taskScheduledBefore(ScheduleProjection.day(before)), + itemFilter: (item) { + final start = item.start; + return start != null && + ScheduleProjection.day( + start, + ).isBefore(ScheduleProjection.day(before)); + }, + ); + } + + Future listNoDateTasks({ + required int limit, + ScheduleFilters filters = const ScheduleFilters(), + }) async { + return _limitedTaskBucket( + limit: limit, + filters: filters, + databaseFilter: _taskNoDate(), + itemFilter: (item) => item.start == null, + ); + } + Future> _accountIds(ScheduleFilters filters) async { if (filters.accountIds.isNotEmpty) { return filters.accountIds.toList(); @@ -81,6 +100,31 @@ class ScheduleRepository { return accounts.map((account) => account.id).toList(); } + Future<_ScheduleAccountContext?> _accountContext( + ScheduleFilters filters, + ) async { + final accountIds = await _accountIds(filters); + if (accountIds.isEmpty) { + return null; + } + final accounts = await (_database.select( + _database.accounts, + )..where((row) => row.id.isIn(accountIds))).get(); + return _ScheduleAccountContext( + accountIds: accountIds, + providers: { + for (final account in accounts) + account.id: TaskProviderParsing.fromStorageValue(account.provider), + }, + accountDisplayNames: { + for (final account in accounts) account.id: account.displayName, + }, + accountEmails: { + for (final account in accounts) account.id: account.email, + }, + ); + } + Future> _calendarItems( ScheduleRange range, ScheduleFilters filters, @@ -134,10 +178,18 @@ class ScheduleRepository { allDay: event.allDay, start: start, end: end, + startTimeZone: event.startTimeZone, + endTimeZone: event.endTimeZone, location: event.location, description: event.description, descriptionContentType: descriptionBody.contentType, descriptionHtml: descriptionBody.html, + categories: _stringListFromJson(event.categoriesJson), + reminderMinutesBeforeStart: _eventReminderMinutes( + provider, + event.remindersJson, + source: source, + ), colorHex: event.colorHex ?? calendarSourceBackgroundColorHex( @@ -181,43 +233,217 @@ class ScheduleRepository { if (filters.taskListFilterActive) { query.where(_database.tasks.taskListId.isIn(filters.taskListIds)); } + if (!filters.showCompletedTasks) { + query.where(_taskIncomplete()); + } + if (!searching) { + final inRange = _taskScheduledInRange(range); + query.where(filters.showNoDateTasks ? inRange | _taskNoDate() : inRange); + } final rows = await query.get(); final items = []; for (final row in rows) { - final task = row.readTable(_database.tasks); - if (!filters.showCompletedTasks && task.status == 'completed') { + final item = _taskItemFromRow( + row, + providers, + accountDisplayNames, + accountEmails, + ); + if (!filters.showCompletedTasks && item.completed) { continue; } - final provider = providers[task.accountId] ?? TaskProvider.google; - final start = _taskStart(task, provider); - final end = _taskEnd(task, provider); + final start = item.start; + final end = item.end; if (start == null && !filters.showNoDateTasks) { continue; } if (start != null && !searching && !_intersects(range, start, end)) { continue; } - final list = row.readTableOrNull(_database.taskLists); - items.add( - TaskScheduleItem( - id: task.id, - accountId: task.accountId, - provider: provider, - sourceId: task.taskListId, - title: task.title, - completed: task.status == 'completed', - allDay: _taskAllDay(task, provider), - start: start, - end: end, - notes: task.notes ?? task.bodyContent, - sourceName: list?.title, - accountDisplayName: accountDisplayNames[task.accountId], - accountEmail: accountEmails[task.accountId], - ), - ); + items.add(item); } return items; } + + Future _limitedTaskBucket({ + required int limit, + required ScheduleFilters filters, + required Expression databaseFilter, + required bool Function(TaskScheduleItem item) itemFilter, + }) async { + if (!filters.includeTasks || + (filters.taskListFilterActive && filters.taskListIds.isEmpty)) { + return const ScheduleTaskBucketPage(items: [], hasMore: false); + } + + final context = await _accountContext(filters); + if (context == null) { + return const ScheduleTaskBucketPage(items: [], hasMore: false); + } + + final effectiveLimit = limit < 1 ? 1 : limit; + final query = + _database.select(_database.tasks).join([ + leftOuterJoin( + _database.taskLists, + _database.taskLists.accountId.equalsExp( + _database.tasks.accountId, + ) & + _database.taskLists.id.equalsExp(_database.tasks.taskListId), + ), + ]) + ..where(_database.tasks.accountId.isIn(context.accountIds)) + ..where(_database.tasks.pendingDelete.equals(false)) + ..where(databaseFilter) + ..limit(effectiveLimit + 1); + if (filters.taskListFilterActive) { + query.where(_database.tasks.taskListId.isIn(filters.taskListIds)); + } + if (!filters.showCompletedTasks) { + query.where(_taskIncomplete()); + } + query.orderBy([ + OrderingTerm.asc(_database.tasks.dueUtc), + OrderingTerm.asc(_database.tasks.microsoftStartDateTime), + OrderingTerm.asc(_database.tasks.microsoftDueDateTime), + OrderingTerm.asc(_database.taskLists.title), + OrderingTerm.asc(_database.tasks.parent), + OrderingTerm.asc(_database.tasks.position), + OrderingTerm.asc(_database.tasks.title), + ]); + + final rows = await query.get(); + final items = []; + for (final row in rows) { + final item = _taskItemFromRow( + row, + context.providers, + context.accountDisplayNames, + context.accountEmails, + ); + if (!filters.showCompletedTasks && item.completed) { + continue; + } + if (!itemFilter(item)) { + continue; + } + items.add(item); + if (items.length > effectiveLimit) { + break; + } + } + + final visibleItems = items.take(effectiveLimit).toList() + ..sort(compareScheduleItems); + return ScheduleTaskBucketPage( + items: visibleItems, + hasMore: items.length > effectiveLimit, + ); + } + + TaskScheduleItem _taskItemFromRow( + TypedResult row, + Map providers, + Map accountDisplayNames, + Map accountEmails, + ) { + final task = row.readTable(_database.tasks); + final provider = providers[task.accountId] ?? TaskProvider.google; + final list = row.readTableOrNull(_database.taskLists); + final start = _taskStart(task, provider); + return TaskScheduleItem( + id: task.id, + accountId: task.accountId, + provider: provider, + sourceId: task.taskListId, + title: task.title, + completed: task.status == 'completed', + allDay: _taskAllDay(task, provider), + start: start, + end: _taskEnd(task, provider), + notes: task.notes ?? task.bodyContent, + categories: _stringListFromJson(task.categoriesJson), + reminder: task.microsoftIsReminderOn == true + ? providerDateTimeAsLocal( + task.microsoftReminderDateTime, + task.microsoftReminderTimeZone, + ) + : null, + sourceName: list?.title, + accountDisplayName: accountDisplayNames[task.accountId], + accountEmail: accountEmails[task.accountId], + ); + } + + Expression _taskIncomplete() { + return _database.tasks.status.isNull() | + _database.tasks.status.equals('completed').not(); + } + + Expression _taskNoDate() { + return _database.tasks.dueUtc.isNull() & + _database.tasks.microsoftStartDateTime.isNull() & + _database.tasks.microsoftDueDateTime.isNull(); + } + + Expression _taskScheduledBefore(DateTime before) { + final beforeKey = _dateKey(before); + return _textBefore(_database.tasks.dueUtc, beforeKey) | + _textBefore(_database.tasks.microsoftStartDateTime, beforeKey) | + _textBefore(_database.tasks.microsoftDueDateTime, beforeKey); + } + + Expression _taskScheduledInRange(ScheduleRange range) { + final startKey = _dateKey(range.start); + final endKey = _dateKey(range.end); + return _textInRange(_database.tasks.dueUtc, startKey, endKey) | + _textInRange(_database.tasks.microsoftStartDateTime, startKey, endKey) | + _textInRange(_database.tasks.microsoftDueDateTime, startKey, endKey); + } +} + +class ScheduleTaskBucketPage { + const ScheduleTaskBucketPage({required this.items, required this.hasMore}); + + final List items; + final bool hasMore; +} + +class _ScheduleAccountContext { + const _ScheduleAccountContext({ + required this.accountIds, + required this.providers, + required this.accountDisplayNames, + required this.accountEmails, + }); + + final List accountIds; + final Map providers; + final Map accountDisplayNames; + final Map accountEmails; +} + +Expression _textBefore(GeneratedColumn value, String upperBound) { + return value.isNotNull() & value.isSmallerThanValue(upperBound); +} + +Expression _textInRange( + GeneratedColumn value, + String lowerBound, + String upperBound, +) { + return value.isNotNull() & + value.isBiggerOrEqualValue(lowerBound) & + value.isSmallerThanValue(upperBound); +} + +String _dateKey(DateTime value) { + final day = DateTime(value.year, value.month, value.day); + return [ + day.year.toString().padLeft(4, '0'), + day.month.toString().padLeft(2, '0'), + day.day.toString().padLeft(2, '0'), + ].join('-'); } ({String? contentType, String? html}) _eventDescriptionBody( @@ -263,8 +489,9 @@ bool matchesScheduleQuery(ScheduleItem item, String query) { if (item is CalendarScheduleItem) ...[ item.location ?? '', item.description ?? '', + ...item.categories, ], - if (item is TaskScheduleItem) item.notes ?? '', + if (item is TaskScheduleItem) ...[item.notes ?? '', ...item.categories], ].map((value) => value.toLowerCase()).toList(); return terms.every((term) => fields.any((field) => field.contains(term))); } @@ -293,11 +520,15 @@ bool _taskAllDay(Task task, BusyProvider provider) { if (provider == TaskProvider.google) { return true; } - final scheduleDateTime = - task.microsoftStartDateTime ?? task.microsoftDueDateTime; - return scheduleDateTime == null || !scheduleDateTime.contains('T'); + final scheduleDateTimes = [ + task.microsoftStartDateTime, + task.microsoftDueDateTime, + ].whereType().where((value) => value.isNotEmpty); + return scheduleDateTimes.isEmpty || scheduleDateTimes.every(_isDateOnly); } +bool _isDateOnly(String value) => !value.contains('T'); + bool _intersects(ScheduleRange range, DateTime? start, DateTime? end) { if (start == null) { return true; @@ -308,14 +539,14 @@ bool _intersects(ScheduleRange range, DateTime? start, DateTime? end) { DateTime? _eventStart(CalendarEvent event) { if (!event.allDay) { - return _parseDateTime(event.startDateTime); + return _parseCalendarDateTime(event.startDateTime); } return _parseDate(event.startDate) ?? _parseDate(event.startDateTime); } DateTime? _eventEnd(CalendarEvent event) { if (!event.allDay) { - return _parseDateTime(event.endDateTime); + return _parseCalendarDateTime(event.endDateTime); } return _parseDate(event.endDate) ?? _parseDate(event.endDateTime); } @@ -333,3 +564,108 @@ DateTime? _parseDateTime(String? value) { } return DateTime.tryParse(value); } + +DateTime? _parseCalendarDateTime(String? value) { + if (value == null || value.isEmpty) { + return null; + } + final offsetWallTime = _parseOffsetWallDateTime(value); + if (offsetWallTime != null) { + return offsetWallTime; + } + final parsed = DateTime.tryParse(value); + if (parsed == null) { + return null; + } + return parsed.isUtc ? parsed.toLocal() : parsed; +} + +DateTime? _parseOffsetWallDateTime(String value) { + if (!RegExp(r'[+-]\d{2}:?\d{2}$').hasMatch(value)) { + return null; + } + final wallTime = value.replaceFirst(RegExp(r'[+-]\d{2}:?\d{2}$'), ''); + return DateTime.tryParse(wallTime); +} + +List _stringListFromJson(String? value) { + if (value == null || value.isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(value); + if (decoded is List) { + return [ + for (final item in decoded) + if (item != null && item.toString().trim().isNotEmpty) + item.toString().trim(), + ]; + } + } on FormatException { + return const []; + } + return const []; +} + +List _eventReminderMinutes( + BusyProvider provider, + String? value, { + CalendarSource? source, +}) { + if (value == null || value.isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(value); + if (decoded is! Map) { + return const []; + } + final map = decoded.cast(); + final minutes = switch (provider) { + TaskProvider.microsoft => + map['isReminderOn'] == true + ? [map['reminderMinutesBeforeStart']] + : const [], + TaskProvider.google => + map['useDefault'] == true + ? _googleDefaultReminderMinutes(source) + : switch (map['overrides']) { + final List overrides => [ + for (final item in overrides) + if (item is Map && item['method'] == 'popup') + item['minutes'], + ], + _ => const [], + }, + }; + return [ + for (final value in minutes) + if (value is int && value >= 0) value, + ]..sort(); + } on FormatException { + return const []; + } +} + +List _googleDefaultReminderMinutes(CalendarSource? source) { + final raw = source?.rawJson; + if (raw == null || raw.isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(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'], + ]; + } on FormatException { + return const []; + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 0251624..1e0ed75 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -94,18 +94,17 @@ set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/busymax.desktop" +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.desktop" DESTINATION "${CMAKE_INSTALL_PREFIX}/share/applications" COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.png" - DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps" - RENAME "io.busystack.busymax.png" +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.metainfo.xml" + DESTINATION "${CMAKE_INSTALL_PREFIX}/share/metainfo" COMPONENT Runtime) -install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.png" - DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/256x256/apps" - RENAME "busymax.png" +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.svg" + DESTINATION "${CMAKE_INSTALL_PREFIX}/share/icons/hicolor/scalable/apps" + RENAME "io.busystack.busymax.svg" COMPONENT Runtime) install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index a246036..cf299bd 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,9 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin"); + desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a8c1e40..99551c6 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + desktop_multi_window file_selector_linux flutter_secure_storage_linux gtk diff --git a/linux/busymax.desktop b/linux/io.busystack.busymax.desktop similarity index 100% rename from linux/busymax.desktop rename to linux/io.busystack.busymax.desktop diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml new file mode 100644 index 0000000..cfceeea --- /dev/null +++ b/linux/io.busystack.busymax.metainfo.xml @@ -0,0 +1,35 @@ + + + + io.busystack.busymax + CC0-1.0 + BusyMax + Calendar and task manager + +

BusyMax is a Linux desktop calendar and task manager built with Flutter.

+

It supports Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do.

+
+ io.busystack.busymax.desktop + io.busystack.busymax + + Office + Calendar + ProjectManagement + Utility + + https://github.com/busystack/busymax + https://github.com/busystack/busymax/issues + https://github.com/busystack/busymax + + + + +
diff --git a/linux/runner/main.cc b/linux/runner/main.cc index e7c5c54..3b8974e 100644 --- a/linux/runner/main.cc +++ b/linux/runner/main.cc @@ -1,6 +1,11 @@ #include "my_application.h" int main(int argc, char** argv) { + // BusyMax uses a tray-attached compact Agenda window. GNOME Wayland does not + // allow normal GTK top-level windows to choose an absolute screen position, + // so the tray popup opens centered. X11/XWayland honors gtk_window_move(), + // which is required for this app-level tray surface. + gdk_set_allowed_backends("x11"); g_autoptr(MyApplication) app = my_application_new(); return g_application_run(G_APPLICATION(app), argc, argv); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 6ced418..c1d5c48 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #ifdef GDK_WINDOWING_X11 @@ -11,6 +12,7 @@ #endif #include "flutter/generated_plugin_registrant.h" +#include "desktop_multi_window/desktop_multi_window_plugin.h" constexpr char kApplicationDisplayName[] = "BusyMax"; constexpr char kNativeDateTimePickerChannel[] = @@ -22,6 +24,8 @@ constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = "io.busystack.busymax/gtk_theme_colors"; +constexpr char kCompactAgendaWindowChannel[] = + "io.busystack.busymax/compact_agenda_window"; constexpr gint kHeaderButtonHeight = 34; constexpr gint kHeaderButtonRadius = 8; constexpr gint kHeaderButtonHorizontalPadding = 8; @@ -37,6 +41,21 @@ constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kHeaderTooltipVerticalPadding = 5; constexpr gint kHeaderTooltipHorizontalPadding = 8; constexpr gint kHeaderWindowRadius = 8; +constexpr gint kCompactAgendaPanelWidth = 420; +constexpr gint kCompactAgendaPanelHeight = 680; +constexpr gint kCompactAgendaWindowShadowMargin = 32; +constexpr gint kCompactAgendaWindowWidth = + kCompactAgendaPanelWidth + kCompactAgendaWindowShadowMargin * 2; +constexpr gint kCompactAgendaWindowHeight = + kCompactAgendaPanelHeight + kCompactAgendaWindowShadowMargin * 2; +constexpr gint kCompactAgendaWindowMinWidth = + 360 + kCompactAgendaWindowShadowMargin * 2; +constexpr gint kCompactAgendaWindowMinHeight = + 520 + kCompactAgendaWindowShadowMargin * 2; +constexpr gint kCompactAgendaWindowMaxWidth = + 480 + kCompactAgendaWindowShadowMargin * 2; +constexpr gint kCompactAgendaWindowMaxHeight = + 840 + kCompactAgendaWindowShadowMargin * 2; constexpr char kDefaultHeaderBarBackgroundColor[] = "#1D1D20"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"; @@ -55,6 +74,7 @@ struct _MyApplication { gboolean gtk_font_settings_listening; gboolean gtk_theme_colors_listening; GtkCssProvider* header_bar_css_provider; + gchar* header_bar_window_background_color; gchar* header_bar_background_color; gchar* header_bar_sidebar_background_color; gchar* header_bar_foreground_color; @@ -110,8 +130,10 @@ struct _MyApplication { gboolean hide_on_close; gboolean suppress_header_bar_actions; gboolean header_schedule_controls_visible; + gboolean header_navigation_visible; gboolean header_back_visible; gboolean header_onboarding_controls_visible; + gboolean main_window_transparent_backing; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -126,7 +148,7 @@ static GdkPixbuf* load_application_icon_at_size(gint size) { g_autofree gchar* executable_dir = g_path_get_dirname(executable_path); g_autofree gchar* icon_path = g_build_filename(executable_dir, "data", "flutter_assets", "assets", - "branding", "busymax-logo.png", nullptr); + "branding", "busymax-logo.svg", nullptr); g_autoptr(GError) error = nullptr; GdkPixbuf* icon = @@ -249,17 +271,31 @@ static void native_date_time_picker_method_call_cb(FlMethodChannel* channel, } } -static void register_native_date_time_picker(MyApplication* self, - FlView* view, - GtkWindow* window) { +static FlMethodChannel* create_native_date_time_picker_channel( + FlView* view, + GtkWindow* window) { g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - self->native_date_time_picker_channel = fl_method_channel_new( + FlMethodChannel* channel = fl_method_channel_new( fl_engine_get_binary_messenger(fl_view_get_engine(view)), kNativeDateTimePickerChannel, FL_METHOD_CODEC(codec)); fl_method_channel_set_method_call_handler( - self->native_date_time_picker_channel, - native_date_time_picker_method_call_cb, g_object_ref(window), + channel, native_date_time_picker_method_call_cb, g_object_ref(window), g_object_unref); + return channel; +} + +static void register_native_date_time_picker(MyApplication* self, + FlView* view, + GtkWindow* window) { + self->native_date_time_picker_channel = + create_native_date_time_picker_channel(view, window); +} + +static void register_native_date_time_picker_for_subwindow(FlView* view, + GtkWindow* window) { + FlMethodChannel* channel = create_native_date_time_picker_channel(view, window); + g_object_set_data_full(G_OBJECT(window), "busymax-native-date-time-picker", + channel, g_object_unref); } static void respond_bool(FlMethodCall* method_call, gboolean value) { @@ -366,6 +402,26 @@ static void set_flutter_view_background_color(MyApplication* self, fl_view_set_background_color(FL_VIEW(self->flutter_view), &background_color); } +static void set_flutter_view_background_transparent(MyApplication* self) { + if (self->flutter_view == nullptr || !FL_IS_VIEW(self->flutter_view)) { + return; + } + + GdkRGBA transparent = {0, 0, 0, 0}; + fl_view_set_background_color(FL_VIEW(self->flutter_view), &transparent); +} + +static void set_main_flutter_view_background(MyApplication* self) { + if (self->main_window_transparent_backing) { + set_flutter_view_background_transparent(self); + return; + } + + set_flutter_view_background_color( + self, css_color_or(self->header_bar_window_background_color, + self->header_bar_background_color)); +} + static gint header_sidebar_effective_width(MyApplication* self) { if (!self->header_bar_sidebar_visible) { return 0; @@ -380,6 +436,11 @@ static void refresh_header_bar_css(MyApplication* self) { } const gchar* background_color = self->header_bar_background_color; + const gchar* window_background_color = + css_color_or(self->header_bar_window_background_color, background_color); + const gchar* window_css_background_color = + self->main_window_transparent_backing ? "transparent" + : window_background_color; const gchar* sidebar_background_color = is_css_color_token(self->header_bar_sidebar_background_color) ? self->header_bar_sidebar_background_color @@ -416,7 +477,7 @@ static void refresh_header_bar_css(MyApplication* self) { g_autofree gchar* css = g_strdup_printf( "window#busymax-window," "window#busymax-window:backdrop {" - "background-color: transparent;" + "background-color: %s;" "background-image: none;" "}" "window#busymax-window decoration," @@ -424,7 +485,9 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: transparent;" "background-image: none;" "border: none;" - "box-shadow: none;" + "box-shadow: 0 3px 18px 2px %s;" + "outline: none;" + "border-radius: %dpx;" "}" ".busymax-titlebar," ".busymax-titlebar:backdrop," @@ -616,6 +679,7 @@ static void refresh_header_bar_css(MyApplication* self) { "padding: 0;" "min-height: 0;" "border-radius: %dpx;" + "box-shadow: 0 5px 18px 2px %s;" "}" "tooltip > box," "tooltip.background > box {" @@ -629,6 +693,7 @@ static void refresh_header_bar_css(MyApplication* self) { "min-height: 0;" "border-radius: %dpx;" "}", + window_css_background_color, shade_color, kHeaderWindowRadius, background_color, kHeaderWindowRadius, kHeaderWindowRadius, kHeaderWindowRadius, sidebar_background_color, kHeaderWindowRadius, foreground_color, foreground_color, modal_barrier_color, @@ -644,7 +709,7 @@ static void refresh_header_bar_css(MyApplication* self) { border_color, shade_color, kHeaderButtonHeight, kHeaderButtonHorizontalPadding, kHeaderButtonRadius, control_hover_color, foreground_color, muted_foreground_color, - kHeaderButtonRadius, kHeaderTooltipVerticalPadding, + kHeaderButtonRadius, shade_color, kHeaderTooltipVerticalPadding, kHeaderTooltipHorizontalPadding, kHeaderButtonRadius); g_autoptr(GError) error = nullptr; @@ -680,6 +745,8 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { return; } + set_css_color_field(&self->header_bar_window_background_color, + fl_lookup_string_arg(args, "windowBackgroundColor")); set_css_color_field(&self->header_bar_background_color, fl_lookup_string_arg(args, "backgroundColor")); set_css_color_field(&self->header_bar_sidebar_background_color, @@ -708,7 +775,7 @@ static void set_header_bar_theme(MyApplication* self, FlValue* args) { fl_lookup_string_arg(args, "shadeColor")); set_css_color_field(&self->header_bar_modal_barrier_color, fl_lookup_string_arg(args, "modalBarrierColor")); - set_flutter_view_background_color(self, self->header_bar_background_color); + set_main_flutter_view_background(self); refresh_header_bar_css(self); } @@ -1198,14 +1265,25 @@ static void set_header_schedule_controls_visible(MyApplication* self, visible || self->header_back_visible); set_widget_visible(self->sidebar_collapsed_toggle_button, visible); set_widget_visible(self->today_button, visible); - set_widget_visible(self->previous_button, visible); - set_widget_visible(self->next_button, visible); + set_widget_visible(self->previous_button, + visible && self->header_navigation_visible); + set_widget_visible(self->next_button, + visible && self->header_navigation_visible); set_widget_visible(self->header_view_box, visible); set_widget_visible(self->search_button, visible); set_widget_visible(self->refresh_button, visible); update_header_title_balance_spacer(self); } +static void set_header_navigation_visible(MyApplication* self, + gboolean visible) { + self->header_navigation_visible = visible; + set_widget_visible(self->previous_button, + self->header_schedule_controls_visible && visible); + set_widget_visible(self->next_button, + self->header_schedule_controls_visible && visible); +} + static void set_header_back_visible(MyApplication* self, gboolean visible) { self->header_back_visible = visible; set_widget_visible(self->back_button, visible); @@ -1590,6 +1668,9 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setSidebarVisible") == 0) { set_header_sidebar_visible(self, fl_method_bool_arg(args)); respond_success(method_call); + } else if (strcmp(method, "setNavigationVisible") == 0) { + set_header_navigation_visible(self, fl_method_bool_arg(args)); + respond_success(method_call); } else if (strcmp(method, "setScheduleControlsVisible") == 0) { set_header_schedule_controls_visible(self, fl_method_bool_arg(args)); respond_success(method_call); @@ -2024,6 +2105,197 @@ static void register_gtk_settings_channel(MyApplication* self, FlView* view) { gtk_theme_colors_cancel_cb, self, nullptr); } +struct CompactGtkSettingsBridge { + FlMethodChannel* settings_channel; + FlEventChannel* font_settings_event_channel; + FlEventChannel* theme_colors_event_channel; + gulong font_settings_signal_id; + gulong theme_name_signal_id; + gulong theme_dark_signal_id; + gboolean font_settings_listening; + gboolean theme_colors_listening; +}; + +static void compact_gtk_settings_bridge_disconnect_font( + CompactGtkSettingsBridge* bridge) { + if (bridge == nullptr || bridge->font_settings_signal_id == 0) { + return; + } + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr) { + g_signal_handler_disconnect(settings, bridge->font_settings_signal_id); + } + bridge->font_settings_signal_id = 0; +} + +static void compact_gtk_settings_bridge_disconnect_theme( + CompactGtkSettingsBridge* bridge) { + if (bridge == nullptr) { + return; + } + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr && bridge->theme_name_signal_id != 0) { + g_signal_handler_disconnect(settings, bridge->theme_name_signal_id); + } + if (settings != nullptr && bridge->theme_dark_signal_id != 0) { + g_signal_handler_disconnect(settings, bridge->theme_dark_signal_id); + } + bridge->theme_name_signal_id = 0; + bridge->theme_dark_signal_id = 0; +} + +static void compact_gtk_settings_bridge_free(gpointer data) { + CompactGtkSettingsBridge* bridge = + static_cast(data); + if (bridge == nullptr) { + return; + } + compact_gtk_settings_bridge_disconnect_font(bridge); + compact_gtk_settings_bridge_disconnect_theme(bridge); + g_clear_object(&bridge->settings_channel); + g_clear_object(&bridge->font_settings_event_channel); + g_clear_object(&bridge->theme_colors_event_channel); + g_free(bridge); +} + +static void compact_gtk_settings_send_font_event( + CompactGtkSettingsBridge* bridge) { + if (bridge == nullptr || !bridge->font_settings_listening || + bridge->font_settings_event_channel == nullptr) { + return; + } + g_autoptr(FlValue) result = get_gtk_font_settings(); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(bridge->font_settings_event_channel, result, + nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send compact GTK font settings event: %s", message); + } +} + +static void compact_gtk_settings_send_theme_event( + CompactGtkSettingsBridge* bridge) { + if (bridge == nullptr || !bridge->theme_colors_listening || + bridge->theme_colors_event_channel == nullptr) { + return; + } + g_autoptr(FlValue) result = get_gtk_theme_colors(); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(bridge->theme_colors_event_channel, result, + nullptr, &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send compact GTK theme colors event: %s", message); + } +} + +static void compact_gtk_font_notify_cb(GObject* object, + GParamSpec* pspec, + gpointer user_data) { + compact_gtk_settings_send_font_event( + static_cast(user_data)); +} + +static void compact_gtk_theme_notify_cb(GObject* object, + GParamSpec* pspec, + gpointer user_data) { + compact_gtk_settings_send_theme_event( + static_cast(user_data)); +} + +static FlMethodErrorResponse* compact_gtk_font_settings_listen_cb( + FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + CompactGtkSettingsBridge* bridge = + static_cast(user_data); + bridge->font_settings_listening = TRUE; + + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr && bridge->font_settings_signal_id == 0) { + bridge->font_settings_signal_id = + g_signal_connect(settings, "notify::gtk-font-name", + G_CALLBACK(compact_gtk_font_notify_cb), bridge); + } + + compact_gtk_settings_send_font_event(bridge); + return nullptr; +} + +static FlMethodErrorResponse* compact_gtk_font_settings_cancel_cb( + FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + CompactGtkSettingsBridge* bridge = + static_cast(user_data); + bridge->font_settings_listening = FALSE; + compact_gtk_settings_bridge_disconnect_font(bridge); + return nullptr; +} + +static FlMethodErrorResponse* compact_gtk_theme_colors_listen_cb( + FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + CompactGtkSettingsBridge* bridge = + static_cast(user_data); + bridge->theme_colors_listening = TRUE; + + GtkSettings* settings = gtk_settings_get_default(); + if (settings != nullptr && bridge->theme_name_signal_id == 0) { + bridge->theme_name_signal_id = + g_signal_connect(settings, "notify::gtk-theme-name", + G_CALLBACK(compact_gtk_theme_notify_cb), bridge); + } + if (settings != nullptr && bridge->theme_dark_signal_id == 0) { + bridge->theme_dark_signal_id = g_signal_connect( + settings, "notify::gtk-application-prefer-dark-theme", + G_CALLBACK(compact_gtk_theme_notify_cb), bridge); + } + + compact_gtk_settings_send_theme_event(bridge); + return nullptr; +} + +static FlMethodErrorResponse* compact_gtk_theme_colors_cancel_cb( + FlEventChannel* channel, + FlValue* args, + gpointer user_data) { + CompactGtkSettingsBridge* bridge = + static_cast(user_data); + bridge->theme_colors_listening = FALSE; + compact_gtk_settings_bridge_disconnect_theme(bridge); + return nullptr; +} + +static void register_compact_gtk_settings_channel(FlView* view, + GtkWindow* window) { + CompactGtkSettingsBridge* bridge = + g_new0(CompactGtkSettingsBridge, 1); + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlBinaryMessenger* messenger = + fl_engine_get_binary_messenger(fl_view_get_engine(view)); + + bridge->settings_channel = fl_method_channel_new( + messenger, kGtkSettingsChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + bridge->settings_channel, gtk_settings_method_call_cb, bridge, nullptr); + + bridge->font_settings_event_channel = fl_event_channel_new( + messenger, kGtkFontSettingsEventChannel, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers( + bridge->font_settings_event_channel, compact_gtk_font_settings_listen_cb, + compact_gtk_font_settings_cancel_cb, bridge, nullptr); + + bridge->theme_colors_event_channel = fl_event_channel_new( + messenger, kGtkThemeColorsEventChannel, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers( + bridge->theme_colors_event_channel, compact_gtk_theme_colors_listen_cb, + compact_gtk_theme_colors_cancel_cb, bridge, nullptr); + + g_object_set_data_full(G_OBJECT(window), "busymax-compact-gtk-settings", + bridge, compact_gtk_settings_bridge_free); +} + static gboolean window_delete_event_cb(GtkWidget* widget, GdkEvent* event, gpointer user_data) { @@ -2091,15 +2363,303 @@ static gboolean clear_transparent_window_cb(GtkWidget* widget, return FALSE; } -static void configure_transparent_window_backing(GtkWindow* window) { +static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data); + +static gboolean rounded_window_configure_event_cb(GtkWidget* widget, + GdkEventConfigure* event, + gpointer user_data); + +static gboolean configure_transparent_window_backing(GtkWindow* window) { GdkScreen* screen = gtk_window_get_screen(window); GdkVisual* visual = gdk_screen_get_rgba_visual(screen); - if (visual != nullptr) { - gtk_widget_set_visual(GTK_WIDGET(window), visual); + if (visual == nullptr) { + return FALSE; } + + gtk_widget_set_visual(GTK_WIDGET(window), visual); gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb), nullptr); + g_signal_connect_after(window, "realize", G_CALLBACK(rounded_window_realize_cb), + nullptr); + g_signal_connect(window, "configure-event", + G_CALLBACK(rounded_window_configure_event_cb), nullptr); + return TRUE; +} + +static cairo_region_t* create_rounded_window_region(gint width, + gint height, + gint radius) { + cairo_region_t* region = cairo_region_create(); + if (width <= 0 || height <= 0) { + return region; + } + + if (radius <= 0 || width < radius * 2 || height < radius * 2) { + const cairo_rectangle_int_t rect = {0, 0, width, height}; + cairo_region_union_rectangle(region, &rect); + return region; + } + + const gdouble radius_squared = radius * radius; + for (gint y = 0; y < height; y++) { + gint inset = 0; + if (y < radius) { + const gdouble dy = radius - y - 1; + inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); + } else if (y >= height - radius) { + const gdouble dy = y - (height - radius); + inset = radius - static_cast(std::sqrt(radius_squared - dy * dy)); + } + + const gint row_width = width - inset * 2; + if (row_width <= 0) { + continue; + } + + const cairo_rectangle_int_t row = {inset, y, row_width, 1}; + cairo_region_union_rectangle(region, &row); + } + + return region; +} + +static void configure_rounded_window_shape(GtkWidget* widget) { + if (widget == nullptr || !GTK_IS_WIDGET(widget) || + !gtk_widget_get_realized(widget)) { + return; + } + + GdkWindow* window = gtk_widget_get_window(widget); + if (window == nullptr || !GDK_IS_WINDOW(window)) { + return; + } + + const GdkWindowState state = gdk_window_get_state(window); + if ((state & GDK_WINDOW_STATE_MAXIMIZED) != 0 || + (state & GDK_WINDOW_STATE_FULLSCREEN) != 0) { + gdk_window_shape_combine_region(window, nullptr, 0, 0); + return; + } + + const gint width = gtk_widget_get_allocated_width(widget); + const gint height = gtk_widget_get_allocated_height(widget); + if (width <= 0 || height <= 0) { + return; + } + + cairo_region_t* region = + create_rounded_window_region(width, height, kHeaderWindowRadius); + gdk_window_shape_combine_region(window, region, 0, 0); + cairo_region_destroy(region); +} + +static void rounded_window_realize_cb(GtkWidget* widget, gpointer user_data) { + configure_rounded_window_shape(widget); +} + +static gboolean rounded_window_configure_event_cb(GtkWidget* widget, + GdkEventConfigure* event, + gpointer user_data) { + configure_rounded_window_shape(widget); + return FALSE; +} + +static void install_compact_agenda_window_css(GtkWindow* window) { + static const gchar* css = + "window#busymax-compact-agenda-window," + "window#busymax-compact-agenda-window:backdrop {" + "background-color: transparent;" + "background-image: none;" + "}" + "window#busymax-compact-agenda-window decoration," + "window#busymax-compact-agenda-window decoration:backdrop {" + "background-color: transparent;" + "background-image: none;" + "border: none;" + "}"; + + g_autoptr(GError) error = nullptr; + GtkCssProvider* provider = gtk_css_provider_new(); + gtk_css_provider_load_from_data(provider, css, -1, &error); + if (error != nullptr) { + g_warning("Failed to load compact Agenda CSS: %s", error->message); + g_object_unref(provider); + return; + } + + gtk_style_context_add_provider_for_screen( + gtk_window_get_screen(window), GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(provider); +} + +static gboolean compact_agenda_number_arg(FlValue* args, + const gchar* key, + gdouble* value) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + FlValue* arg = fl_value_lookup_string(args, key); + if (arg == nullptr) { + return FALSE; + } + + switch (fl_value_get_type(arg)) { + case FL_VALUE_TYPE_FLOAT: + *value = fl_value_get_float(arg); + return std::isfinite(*value); + case FL_VALUE_TYPE_INT: + *value = static_cast(fl_value_get_int(arg)); + return std::isfinite(*value); + default: + return FALSE; + } +} + +static gint compact_agenda_dimension_arg(FlValue* args, + const gchar* key, + gint fallback, + gint minimum, + gint maximum) { + gdouble value = fallback; + if (!compact_agenda_number_arg(args, key, &value)) { + return fallback; + } + if (value < minimum) { + return minimum; + } + if (value > maximum) { + return maximum; + } + return static_cast(value); +} + +static gboolean compact_agenda_position_arg(FlValue* args, + const gchar* key, + gint* value) { + gdouble parsed = 0; + if (!compact_agenda_number_arg(args, key, &parsed)) { + return FALSE; + } + *value = static_cast(parsed); + return TRUE; +} + +static void apply_compact_agenda_geometry(GtkWindow* window, FlValue* args) { + const gint width = compact_agenda_dimension_arg( + args, "width", kCompactAgendaWindowWidth, kCompactAgendaWindowMinWidth, + kCompactAgendaWindowMaxWidth); + const gint height = compact_agenda_dimension_arg( + args, "height", kCompactAgendaWindowHeight, kCompactAgendaWindowMinHeight, + kCompactAgendaWindowMaxHeight); + + gtk_window_set_position(window, GTK_WIN_POS_NONE); + gtk_window_set_default_size(window, width, height); + gtk_window_resize(window, width, height); + gtk_widget_set_size_request(GTK_WIDGET(window), width, height); + + GtkWidget* child = gtk_bin_get_child(GTK_BIN(window)); + if (child != nullptr) { + gtk_widget_set_size_request(child, width, height); + } + + gint x = 0; + gint y = 0; + if (compact_agenda_position_arg(args, "x", &x) && + compact_agenda_position_arg(args, "y", &y)) { + gtk_window_move(window, x, y); + } +} + +static void compact_agenda_window_method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + GtkWindow* window = GTK_WINDOW(user_data); + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "setPlacement") == 0) { + apply_compact_agenda_geometry(window, args); + respond_bool(method_call, TRUE); + } else if (strcmp(method, "show") == 0) { + apply_compact_agenda_geometry(window, args); + gtk_widget_show(GTK_WIDGET(window)); + gtk_window_present(window); + apply_compact_agenda_geometry(window, args); + respond_bool(method_call, TRUE); + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static void register_compact_agenda_window_channel(FlView* view, + GtkWindow* window) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kCompactAgendaWindowChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + channel, compact_agenda_window_method_call_cb, g_object_ref(window), + g_object_unref); + g_object_set_data_full(G_OBJECT(window), "busymax-compact-agenda-channel", + channel, g_object_unref); +} + +static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { + // BusyMax creates desktop_multi_window subwindows for the compact tray + // Agenda. Configure the GTK shell before Dart/window_manager can show the + // plugin's default 1280x720 secondary window. + if (!FL_IS_VIEW(registry)) { + return; + } + + FlView* view = FL_VIEW(registry); + GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view)); + if (!GTK_IS_WINDOW(toplevel)) { + return; + } + + GtkWindow* window = GTK_WINDOW(toplevel); + GdkRGBA transparent = {0, 0, 0, 0}; + fl_view_set_background_color(view, &transparent); + + gtk_widget_set_name(GTK_WIDGET(window), "busymax-compact-agenda-window"); + install_compact_agenda_window_css(window); + GtkWidget* titlebar = gtk_window_get_titlebar(window); + if (titlebar != nullptr) { + gtk_widget_hide(titlebar); + } + gtk_window_set_decorated(window, FALSE); + gtk_window_set_type_hint(window, GDK_WINDOW_TYPE_HINT_UTILITY); + gtk_window_set_skip_taskbar_hint(window, TRUE); + gtk_window_set_skip_pager_hint(window, TRUE); + gtk_window_set_keep_above(window, TRUE); + gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST); + gtk_window_set_position(window, GTK_WIN_POS_NONE); + gtk_window_set_title(window, "BusyMax Agenda"); + gtk_window_set_resizable(window, FALSE); + gtk_window_set_default_size(window, kCompactAgendaWindowWidth, + kCompactAgendaWindowHeight); + gtk_window_resize(window, kCompactAgendaWindowWidth, + kCompactAgendaWindowHeight); + gtk_widget_set_size_request(GTK_WIDGET(window), kCompactAgendaWindowWidth, + kCompactAgendaWindowHeight); + gtk_widget_set_size_request(GTK_WIDGET(view), kCompactAgendaWindowWidth, + kCompactAgendaWindowHeight); + + GdkGeometry geometry = {}; + geometry.min_width = kCompactAgendaWindowMinWidth; + geometry.min_height = kCompactAgendaWindowMinHeight; + geometry.max_width = kCompactAgendaWindowMaxWidth; + geometry.max_height = kCompactAgendaWindowMaxHeight; + gtk_window_set_geometry_hints( + window, GTK_WIDGET(window), &geometry, + static_cast(GDK_HINT_MIN_SIZE | GDK_HINT_MAX_SIZE)); + + register_compact_agenda_window_channel(view, window); + register_compact_gtk_settings_channel(view, window); + register_native_date_time_picker_for_subwindow(view, window); } // Called when first Flutter frame received. @@ -2114,7 +2674,8 @@ static void my_application_activate(GApplication* application) { GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); self->main_window = window; gtk_widget_set_name(GTK_WIDGET(window), "busymax-window"); - configure_transparent_window_backing(window); + self->main_window_transparent_backing = + configure_transparent_window_backing(window); // Use a header bar when running in GNOME as this is the common style used // by applications and is the setup most users will be using (e.g. Ubuntu @@ -2147,10 +2708,11 @@ static void my_application_activate(GApplication* application) { gtk_window_set_icon(window, application_icon); } G_GNUC_BEGIN_IGNORE_DEPRECATIONS - gtk_window_set_wmclass( - window, APPLICATION_ID, APPLICATION_ID); + gtk_window_set_wmclass(window, APPLICATION_ID, APPLICATION_ID); G_GNUC_END_IGNORE_DEPRECATIONS - gtk_window_set_icon_name(window, APPLICATION_ID); + if (application_icon == nullptr) { + gtk_window_set_icon_name(window, APPLICATION_ID); + } gtk_window_set_default_size(window, 1280, 720); g_signal_connect(window, "delete-event", G_CALLBACK(window_delete_event_cb), self); @@ -2161,11 +2723,7 @@ static void my_application_activate(GApplication* application) { FlView* view = fl_view_new(project); track_widget_pointer(&self->flutter_view, GTK_WIDGET(view)); - GdkRGBA background_color; - gdk_rgba_parse(&background_color, - css_color_or(self->header_bar_background_color, - kDefaultHeaderBarBackgroundColor)); - fl_view_set_background_color(view, &background_color); + set_main_flutter_view_background(self); gtk_widget_show(GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); @@ -2176,6 +2734,11 @@ static void my_application_activate(GApplication* application) { gtk_widget_realize(GTK_WIDGET(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + desktop_multi_window_plugin_set_window_created_callback( + [](FlPluginRegistry* registry) { + configure_compact_agenda_subwindow(registry); + fl_register_plugins(registry); + }); register_native_date_time_picker(self, view, window); register_window_channel(self, view); register_header_bar_channel(self, view); @@ -2273,6 +2836,7 @@ static void my_application_dispose(GObject* object) { clear_widget_pointer(&self->view_mode_agenda_item); clear_widget_pointer(&self->search_button); clear_widget_pointer(&self->refresh_button); + g_clear_pointer(&self->header_bar_window_background_color, g_free); g_clear_pointer(&self->header_bar_background_color, g_free); g_clear_pointer(&self->header_bar_sidebar_background_color, g_free); g_clear_pointer(&self->header_bar_foreground_color, g_free); @@ -2318,7 +2882,10 @@ static void my_application_init(MyApplication* self) { self->header_schedule_controls_visible = TRUE; self->header_back_visible = FALSE; self->header_onboarding_controls_visible = FALSE; + self->main_window_transparent_backing = FALSE; self->header_bar_css_provider = nullptr; + self->header_bar_window_background_color = + g_strdup(kDefaultHeaderBarBackgroundColor); self->header_bar_background_color = g_strdup(kDefaultHeaderBarBackgroundColor); self->header_bar_sidebar_background_color = @@ -2373,6 +2940,7 @@ static void my_application_init(MyApplication* self) { self->search_button = nullptr; self->refresh_button = nullptr; self->header_view_mode = nullptr; + self->header_navigation_visible = TRUE; } MyApplication* my_application_new() { diff --git a/linux/runner/resources/icons/128x128/apps/busymax.png b/linux/runner/resources/icons/128x128/apps/busymax.png deleted file mode 100644 index 6749c88..0000000 Binary files a/linux/runner/resources/icons/128x128/apps/busymax.png and /dev/null differ diff --git a/linux/runner/resources/icons/256x256/apps/busymax.png b/linux/runner/resources/icons/256x256/apps/busymax.png deleted file mode 100644 index 6749c88..0000000 Binary files a/linux/runner/resources/icons/256x256/apps/busymax.png and /dev/null differ diff --git a/linux/runner/resources/icons/64x64/apps/busymax.png b/linux/runner/resources/icons/64x64/apps/busymax.png deleted file mode 100644 index 6749c88..0000000 Binary files a/linux/runner/resources/icons/64x64/apps/busymax.png and /dev/null differ diff --git a/pubspec.lock b/pubspec.lock index e28d1db..be86215 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" + desktop_multi_window: + dependency: "direct main" + description: + name: desktop_multi_window + sha256: "60ba38725b8887b60e44d15afdcf0c3813568b5da2ccaf1e7f6fd09a380a6e24" + url: "https://pub.dev" + source: hosted + version: "0.3.0" desktop_notifications: dependency: "direct main" description: @@ -909,7 +917,7 @@ packages: source: hosted version: "2.6.1" screen_retriever: - dependency: transitive + dependency: "direct main" description: name: screen_retriever sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" @@ -1258,7 +1266,7 @@ packages: source: hosted version: "6.3.0" window_manager: - dependency: transitive + dependency: "direct main" description: name: window_manager sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" diff --git a/pubspec.yaml b/pubspec.yaml index 2f17cd2..7b36199 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.0 + desktop_multi_window: ^0.3.0 desktop_notifications: ^0.6.3 drift: ^2.33.0 file_selector: ^1.0.3 @@ -27,6 +28,7 @@ dependencies: package_info_plus: ^10.1.0 path: ^1.9.0 path_provider: ^2.1.0 + screen_retriever: ^0.2.0 sqlite3: ^3.3.0 sqlite3_flutter_libs: ^0.6.0 system_theme: ^3.3.0 @@ -34,6 +36,7 @@ dependencies: ubuntu_widgets: ^0.8.1 url_launcher: ^6.3.0 uuid: ^4.5.0 + window_manager: ^0.5.1 xdg_status_notifier_item: ^0.0.1 yaru: ^10.1.0 @@ -52,7 +55,7 @@ flutter: generate: true uses-material-design: true assets: - - assets/branding/busymax-logo.png + - assets/branding/busymax-logo.svg # Do not add google_sign_in for Linux OAuth. It does not support Linux. # Do not add extension_google_sign_in_as_googleapis_auth for Linux OAuth. diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 68d50c5..6a368d6 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -28,92 +28,120 @@ void main() { ); }); - test('Task Details and Settings use BusyMax Yaru row patterns', () { - final taskDetails = File( - 'lib/src/features/tasks/presentation/task_details_editor.dart', - ).readAsStringSync(); - final settings = File( - 'lib/src/features/settings/presentation/settings_screen.dart', - ).readAsStringSync(); - final diagnostics = File( - 'lib/src/features/diagnostics/presentation/diagnostics_screen.dart', - ).readAsStringSync(); - final router = File('lib/src/app/app_router.dart').readAsStringSync(); - final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - final dateTimeFields = File( - 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', - ).readAsStringSync(); - final newTaskDialog = File( - 'lib/src/features/tasks/presentation/new_task_dialog.dart', - ).readAsStringSync(); - - expect(design, contains('class BusyMaxClamp')); - expect(design, contains('class BusyMaxGroupedList')); - expect(design, contains('class BusyMaxActionRow')); - expect(design, contains('class BusyMaxComboRow')); - expect(design, contains('class BusyMaxSwitchRow')); - expect(design, contains('class BusyMaxDialogShell')); - expect(design, contains('class BusyMaxModalEditorSurface')); - expect(design, contains('Color busyMaxModalBarrierColor')); - expect(design, contains('abstract final class BusyMaxElevation')); - expect(design, contains('elevation: BusyMaxElevation.surface')); - expect( - design, - contains('shadowColor: BusyMaxShadow.floatingColor(context)'), - ); - expect(design, contains('final bool filled;')); - expect(design, contains('BusyMaxSurfaceColors.of(context)')); - expect(design, contains('surfaceColors.card')); - expect(design, contains('surfaceColors.control')); - expect(design, isNot(contains('YaruTileList(children: children)'))); - - expect(taskDetails, contains('BusyMaxClamp')); - expect(taskDetails, contains('BusyMaxGroupedList')); - expect(taskDetails, contains('BusyMaxActionRow')); - expect(taskDetails, contains('BusyMaxComboRow')); - - expect(settings, contains('BusyMaxClamp')); - expect(settings, contains('BusyMaxGroupedList')); - expect(settings, contains('BusyMaxActionRow')); - expect(settings, contains('BusyMaxComboRow')); - expect(settings, contains('BusyMaxSwitchRow')); - expect(settings, contains('class _SettingsSidebar')); - expect(settings, contains('enum SettingsPage')); - expect(settings, contains('filled: true')); - expect(settings, contains('DiagnosticsPanel(scrollable: false)')); - expect(settings, isNot(contains("context.go('/diagnostics')"))); - expect(diagnostics, contains('class DiagnosticsPanel')); - expect(diagnostics, isNot(contains('class DiagnosticsScreen'))); - expect(diagnostics, isNot(contains('Scaffold('))); - expect(router, isNot(contains("path: '/diagnostics'"))); - expect(router, isNot(contains('DiagnosticsScreen'))); - expect(settings, contains('SettingsPage.system')); - expect(settings, contains('l10n.manualFullSync')); - expect(settings, contains('l10n.currentLocale')); - expect(settings, isNot(contains('SettingsPage.sync'))); - expect(settings, isNot(contains('SettingsPage.appearance'))); - expect(settings, isNot(contains('SettingsPage.localization'))); - expect(settings, isNot(contains('l10n.themeFamily'))); - expect(settings, contains('setBackVisible(true)')); - expect(settings, contains('setSidebarVisible(true)')); - expect(newTaskDialog, contains('BusyMaxDialogShell')); - - expect(dateTimeFields, contains('YaruDateTimeEntry')); - expect(dateTimeFields, contains('YaruTimeEntry')); - expect(dateTimeFields, isNot(contains('showDatePicker'))); - expect(dateTimeFields, isNot(contains('showTimePicker'))); - }); + test( + 'Task Details, Settings, and Agenda use BusyMax Yaru row patterns', + () { + final taskDetails = File( + 'lib/src/features/tasks/presentation/task_details_editor.dart', + ).readAsStringSync(); + final settings = File( + 'lib/src/features/settings/presentation/settings_screen.dart', + ).readAsStringSync(); + final diagnostics = File( + 'lib/src/features/diagnostics/presentation/diagnostics_screen.dart', + ).readAsStringSync(); + final router = File('lib/src/app/app_router.dart').readAsStringSync(); + final design = File( + 'lib/src/app/busymax_design.dart', + ).readAsStringSync(); + final dateTimeFields = File( + 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', + ).readAsStringSync(); + final newTaskDialog = File( + 'lib/src/features/tasks/presentation/new_task_dialog.dart', + ).readAsStringSync(); + final scheduleAgenda = File( + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + ).readAsStringSync(); + final compactAgenda = File( + 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + ).readAsStringSync(); + + expect(design, contains('class BusyMaxClamp')); + expect(design, contains('class BusyMaxGroupedList')); + expect(design, contains('class BusyMaxActionRow')); + expect(design, contains('class BusyMaxComboRow')); + expect(design, contains('class BusyMaxSwitchRow')); + expect(design, contains('class BusyMaxDialogShell')); + expect(design, contains('class BusyMaxModalEditorSurface')); + expect(design, contains('Color busyMaxModalBarrierColor')); + expect(design, contains('abstract final class BusyMaxElevation')); + expect(design, contains('elevation: BusyMaxElevation.surface')); + expect( + design, + contains('shadowColor: BusyMaxShadow.floatingColor(context)'), + ); + expect(design, contains('final bool filled;')); + expect(design, contains('BusyMaxSurfaceColors.of(context)')); + expect(design, contains('surfaceColors.card')); + expect(design, contains('surfaceColors.control')); + expect(design, isNot(contains('YaruTileList(children: children)'))); + + expect(taskDetails, contains('BusyMaxClamp')); + expect(taskDetails, contains('BusyMaxGroupedList')); + expect(taskDetails, contains('BusyMaxActionRow')); + expect(taskDetails, contains('BusyMaxComboRow')); + + expect(settings, contains('BusyMaxClamp')); + expect(settings, contains('BusyMaxGroupedList')); + expect(settings, contains('BusyMaxActionRow')); + expect(settings, contains('BusyMaxComboRow')); + expect(settings, contains('BusyMaxSwitchRow')); + expect(settings, contains('class _SettingsSidebar')); + expect(settings, contains('enum SettingsPage')); + expect(settings, contains('filled: true')); + expect(settings, contains('DiagnosticsPanel(scrollable: false)')); + expect(settings, isNot(contains("context.go('/diagnostics')"))); + expect(diagnostics, contains('class DiagnosticsPanel')); + expect(diagnostics, isNot(contains('class DiagnosticsScreen'))); + expect(diagnostics, isNot(contains('Scaffold('))); + expect(router, isNot(contains("path: '/diagnostics'"))); + expect(router, isNot(contains('DiagnosticsScreen'))); + expect(settings, contains('SettingsPage.system')); + expect(settings, contains('l10n.manualFullSync')); + expect(settings, contains('l10n.currentLocale')); + expect(settings, isNot(contains('SettingsPage.sync'))); + expect(settings, isNot(contains('SettingsPage.appearance'))); + expect(settings, isNot(contains('SettingsPage.localization'))); + expect(settings, isNot(contains('l10n.themeFamily'))); + expect(settings, contains('setBackVisible(true)')); + expect(settings, contains('setSidebarVisible(true)')); + expect(newTaskDialog, contains('showBusyMaxModalEditorDialog')); + expect(newTaskDialog, contains('TaskDetailsEditor')); + expect(newTaskDialog, isNot(contains('BusyMaxDialogShell'))); + + expect(scheduleAgenda, contains('BusyMaxGroupedList')); + expect(scheduleAgenda, contains('BusyMaxActionRow')); + expect(scheduleAgenda, isNot(contains('scheduleAgendaRowBackground'))); + expect(scheduleAgenda, isNot(contains('class _AgendaDayHeader'))); + expect(scheduleAgenda, isNot(contains('class _AgendaPlainHeader'))); + + expect(compactAgenda, contains('BusyMaxGroupedList')); + expect(compactAgenda, contains('BusyMaxActionRow')); + expect(compactAgenda, isNot(contains('scheduleAgendaRowBackground'))); + + expect(dateTimeFields, contains('YaruDateTimeEntry')); + expect(dateTimeFields, contains('_BusyMaxTimeTextEntry')); + expect(dateTimeFields, contains('parseTimeInput')); + expect(dateTimeFields, isNot(contains('showDatePicker'))); + expect(dateTimeFields, isNot(contains('showTimePicker'))); + }, + ); test('tray DBus menu labels come from injected labels', () { final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); + final logo = File('assets/branding/busymax-logo.svg').readAsStringSync(); expect(source, contains('class BusyMaxTrayLabels')); expect(source, contains('buildBusyMaxTrayMenu')); expect(source, contains('busyMaxApplicationId')); expect(source, contains('io.busystack.busymax')); - expect(source, contains('assets/branding/busymax-logo.png')); + expect(source, contains('assets/branding/busymax-logo.svg')); + expect(logo, contains('width="512" height="512"')); + expect(logo, contains('viewBox="106 108 300 300"')); + expect(logo, isNot(contains('viewBox="254 120 232 272"'))); expect(source, isNot(contains('BusyMaxTrayAgendaSnapshot'))); expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); expect(source, isNot(contains('buildBusyMaxTrayAgendaRows'))); @@ -128,8 +156,117 @@ void main() { expect(source, isNot(contains("label: 'Quit BusyMax'"))); }); + test('compact agenda uses a separate desktop window', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final linuxMain = File('linux/runner/main.cc').readAsStringSync(); + final tray = File( + 'lib/src/platform/busymax_tray_service.dart', + ).readAsStringSync(); + final router = File('lib/src/app/app_router.dart').readAsStringSync(); + final main = File('lib/main.dart').readAsStringSync(); + final compactApp = File( + 'lib/src/features/schedule/presentation/compact_agenda_app.dart', + ).readAsStringSync(); + final compactPanel = File( + 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + ).readAsStringSync(); + final compactWindowService = File( + 'lib/src/platform/compact_agenda_window_service.dart', + ).readAsStringSync(); + + expect(pubspec, contains('desktop_multi_window:')); + expect(pubspec, contains('window_manager:')); + expect(linuxMain, contains('gdk_set_allowed_backends("x11")')); + expect( + runner, + contains('desktop_multi_window_plugin_set_window_created_callback'), + ); + expect(runner, contains('configure_compact_agenda_subwindow')); + expect(runner, contains('install_compact_agenda_window_css')); + expect(runner, contains('io.busystack.busymax/compact_agenda_window')); + expect(runner, contains('kCompactAgendaPanelWidth = 420')); + expect(runner, contains('kCompactAgendaWindowShadowMargin = 32')); + expect( + runner, + contains('gtk_window_resize(window, kCompactAgendaWindowWidth'), + ); + expect(runner, contains('gtk_window_move(window, x, y)')); + expect(runner, contains('apply_compact_agenda_geometry')); + expect( + runner, + contains( + 'gtk_window_set_type_hint(window, GDK_WINDOW_TYPE_HINT_UTILITY)', + ), + ); + expect( + runner, + contains('gtk_window_set_skip_taskbar_hint(window, TRUE)'), + ); + expect(runner, contains('gtk_window_set_skip_pager_hint(window, TRUE)')); + expect(runner, contains('gtk_window_set_keep_above(window, TRUE)')); + expect(runner, contains('register_compact_gtk_settings_channel')); + expect( + runner, + contains('register_native_date_time_picker_for_subwindow'), + ); + expect(runner, contains('window#busymax-compact-agenda-window')); + expect(runner, contains('gtk_window_get_titlebar(window)')); + expect(runner, contains('gtk_widget_hide(titlebar)')); + expect(runner, contains('gtk_window_set_decorated(window, FALSE)')); + expect( + runner, + contains('gtk_widget_set_size_request(GTK_WIDGET(window)'), + ); + expect(runner, contains('gtk_widget_set_size_request(GTK_WIDGET(view)')); + expect( + runner, + isNot(contains('gtk_window_set_titlebar(window, nullptr)')), + ); + expect(tray, contains('return _onOpenAgenda();')); + expect(tray, isNot(contains('BusyMaxTrayAgendaSnapshot'))); + expect(tray, isNot(contains('BusyMaxTrayAgendaEntry'))); + expect(router, isNot(contains('/tray-agenda'))); + expect(compactApp, isNot(contains('linux_header_bar_service.dart'))); + expect(compactApp, contains('gtk_font_service.dart')); + expect(compactApp, contains('gtkFontSettingsProvider')); + expect(compactApp, contains('gtkThemeColorsProvider')); + expect(compactApp, isNot(contains('syncSchedulerProvider'))); + expect(compactApp, isNot(contains('notificationSchedulerProvider'))); + expect(compactApp, isNot(contains('dueTodayNotificationProvider'))); + expect(compactApp, contains('const _compactAgendaPanelWidth = 420.0')); + expect(compactApp, contains('const _compactAgendaPanelHeight = 680.0')); + expect(compactApp, contains('BusyMaxShadow.windowMargin')); + expect( + compactApp, + contains('io.busystack.busymax/compact_agenda_window'), + ); + expect( + compactApp, + contains('_compactAgendaWindowChannel.invokeMethod'), + ); + expect( + compactApp, + contains('await windowManager.setSize(_compactAgendaWindowSize)'), + ); + expect(compactApp, contains('await windowManager.setBounds(')); + expect(compactApp, contains('await _moveNearTrayArea(')); + expect(compactApp, isNot(contains('void onWindowBlur()'))); + expect(compactApp, isNot(contains('_hideAfterBlurDelay'))); + expect(compactPanel, contains('ClipRRect')); + expect(compactPanel, contains('BusyMaxRadius.window')); + expect(compactPanel, contains('BusyMaxShadow.windowShadowsFor')); + expect(compactWindowService, isNot(contains('controller.show()'))); + expect(main, isNot(contains('waitUntilReadyToShow'))); + expect(main, isNot(contains('await windowManager.show();'))); + }); + test('native headerbar keeps sidebar top branded and aligned', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); + final headerBarSource = source.substring( + 0, + source.indexOf('static void install_compact_agenda_window_css'), + ); expect(source, isNot(contains('GtkWidget* brand_box'))); expect( @@ -253,6 +390,26 @@ void main() { expect(source, contains('kHeaderWindowRadius')); expect(source, contains('border-top-left-radius: %dpx;')); expect(source, contains('border-top-right-radius: %dpx;')); + final mainWindowCssStart = source.indexOf('"window#busymax-window,"'); + final mainWindowDecorationCssStart = source.indexOf( + '"window#busymax-window decoration,"', + mainWindowCssStart, + ); + final mainWindowCss = source.substring( + mainWindowCssStart, + mainWindowDecorationCssStart, + ); + expect(mainWindowCss, contains('"background-color: %s;"')); + expect( + source, + contains('self->main_window_transparent_backing ? "transparent"'), + ); + expect( + source, + contains( + 'g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb)', + ), + ); expect(source, contains('clear_transparent_window_cb')); expect(source, contains('CAIRO_OPERATOR_CLEAR')); expect( @@ -261,12 +418,18 @@ void main() { ); expect(source, contains('window#busymax-window decoration,')); expect(source, contains('window#busymax-window decoration:backdrop')); + expect(source, contains('"box-shadow: 0 3px 18px 2px %s;"')); + expect(source, contains('window_css_background_color, shade_color')); + expect(source, contains('"outline: none;"')); expect( source, - contains( - 'g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb)', - ), + contains('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), ); + expect(source, contains('configure_rounded_window_shape')); + expect(source, contains('create_rounded_window_region')); + expect(source, contains('gdk_window_shape_combine_region')); + expect(source, contains('GDK_WINDOW_STATE_MAXIMIZED')); + expect(source, contains('GDK_WINDOW_STATE_FULLSCREEN')); expect(source, isNot(contains('kHeaderSidebarEdgeCompensation'))); expect(source, isNot(contains('-kHeaderSidebarEdgeCompensation'))); expect(source, isNot(contains('linear-gradient(to right'))); @@ -279,6 +442,7 @@ void main() { expect(source, contains('tooltip label')); expect(source, contains('margin: 0;')); expect(source, contains('min-height: 0;')); + expect(source, contains('"box-shadow: 0 5px 18px 2px %s;"')); expect(source, contains('kHeaderTooltipVerticalPadding')); expect(source, contains('kHeaderTooltipHorizontalPadding')); expect( @@ -432,13 +596,19 @@ void main() { expect(source, isNot(contains('gtk_menu_popup_at_widget'))); expect(source, isNot(contains('gtk_menu_shell_append'))); expect(source, isNot(contains('gtk_menu_popdown'))); - expect(source, isNot(contains('gtk_window_set_skip_taskbar_hint'))); - expect(source, isNot(contains('gtk_window_set_skip_pager_hint'))); + expect( + headerBarSource, + isNot(contains('gtk_window_set_skip_taskbar_hint')), + ); + expect( + headerBarSource, + isNot(contains('gtk_window_set_skip_pager_hint')), + ); expect(source, isNot(contains('create_header_popup_box'))); expect(source, isNot(contains('draw_header_popup_background_cb'))); expect(source, isNot(contains('gtk_event_box_new()'))); expect(source, isNot(contains('gtk_widget_set_app_paintable(popup'))); - expect(source, isNot(contains('gtk_window_move'))); + expect(headerBarSource, isNot(contains('gtk_window_move'))); expect(source, isNot(contains('override_header_menu_colors'))); expect(source, isNot(contains('GTK_STYLE_PROVIDER_PRIORITY_USER'))); expect(source, isNot(contains('add_header_menu_provider_to_widget'))); @@ -539,12 +709,11 @@ void main() { contains('kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"'), ); expect(source, contains('set_flutter_view_background_color')); + expect(source, contains('header_bar_window_background_color')); + expect(source, contains('"windowBackgroundColor"')); expect( source, - contains( - 'set_flutter_view_background_color(self, ' - 'self->header_bar_background_color)', - ), + contains('css_color_or(self->header_bar_window_background_color,'), ); expect( source, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index b78500f..d10a1e4 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -13,6 +13,7 @@ import 'package:busymax/src/app/app_theme.dart'; import 'package:busymax/src/app/busymax_app.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; @@ -143,7 +144,7 @@ void main() { expect((light.tooltipTheme.decoration! as BoxDecoration).border, isNull); expect( (light.tooltipTheme.decoration! as BoxDecoration).boxShadow, - BusyMaxShadow.floatingShadows(lightColors.shade), + BusyMaxShadow.tooltipShadows(lightColors.shade), ); expect( (dark.tooltipTheme.decoration! as BoxDecoration).color, @@ -152,7 +153,7 @@ void main() { expect((dark.tooltipTheme.decoration! as BoxDecoration).border, isNull); expect( (dark.tooltipTheme.decoration! as BoxDecoration).boxShadow, - BusyMaxShadow.floatingShadows(darkColors.shade), + BusyMaxShadow.tooltipShadows(darkColors.shade), ); expect(light.tooltipTheme.textStyle?.color, lightColors.foreground); expect(dark.tooltipTheme.textStyle?.color, darkColors.foreground); @@ -761,6 +762,10 @@ void main() { test('ThemeMode.system is the default', () { expect(AppSettings.defaults().themeMode, ThemeMode.system); expect(AppSettings.defaults().scheduleViewMode, ScheduleViewMode.week); + expect( + AppSettings.defaults().notificationDetailLevel, + NotificationDetailLevel.normal, + ); }); test('light and dark override persists', () async { @@ -793,6 +798,27 @@ void main() { expect(store.json['scheduleViewMode'], 'month'); }); + test('notification detail settings stay synchronized', () async { + final store = _MemorySettingsStore(); + final settings = AppSettingsController(store); + await Future.delayed(Duration.zero); + + await settings.setNotificationDetailLevel(NotificationDetailLevel.private); + expect(settings.state.detailedNotifications, isFalse); + + await settings.setDetailedNotifications(true); + expect( + settings.state.notificationDetailLevel, + NotificationDetailLevel.normal, + ); + + final loaded = AppSettings.fromJson({ + 'detailedNotifications': true, + 'notificationDetailLevel': 'private', + }); + expect(loaded.notificationDetailLevel, NotificationDetailLevel.normal); + }); + test('native headerbar receives semantic surface colors', () { final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); @@ -801,6 +827,7 @@ void main() { contains('final colors = BusyMaxSurfaceColors.of(context);'), ); expect(source, contains('await service.setTheme(')); + expect(source, contains('windowBackgroundColor: colors.window')); expect(source, contains('backgroundColor: colors.view')); expect(source, contains('sidebarBackgroundColor: colors.sidebar')); expect(source, contains('controlHoverColor: colors.controlHover')); @@ -812,12 +839,20 @@ void main() { expect(source, isNot(contains('setSidebarBackgroundColor('))); }); - test('root window clip avoids transparent black corner fringes', () { - final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); + test( + 'root window wrapper clips bottom corners over matching native backing', + () { + final source = File('lib/src/app/busymax_app.dart').readAsStringSync(); - expect(source, contains('clipBehavior: Clip.antiAliasWithSaveLayer')); - expect(source, contains('color: BusyMaxSurfaceColors.of(context).window')); - }); + expect(source, contains('ClipRRect(')); + expect(source, contains('bottom: Radius.circular(BusyMaxRadius.window)')); + expect(source, contains('clipBehavior: Clip.antiAliasWithSaveLayer')); + expect( + source, + contains('color: BusyMaxSurfaceColors.of(context).window'), + ); + }, + ); test('signed-out onboarding background matches main content surface', () { final source = File( @@ -853,10 +888,14 @@ void main() { testWidgets('BusyMaxApp wires localization delegates and system theme', ( tester, ) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + await tester.pumpWidget( ProviderScope( overrides: [ buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), ], child: const BusyMaxApp(), diff --git a/test/core/time/provider_date_time_test.dart b/test/core/time/provider_date_time_test.dart new file mode 100644 index 0000000..c23f7aa --- /dev/null +++ b/test/core/time/provider_date_time_test.dart @@ -0,0 +1,27 @@ +import 'package:busymax/src/core/time/provider_date_time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('UTC provider dateTime without offset is treated as a UTC instant', () { + expect( + providerDateTimeAsUtcInstant('2026-06-08T13:15:00', 'UTC'), + DateTime.utc(2026, 6, 8, 13, 15), + ); + expect( + providerDateTimeAsLocal('2026-06-08T13:15:00', 'UTC'), + DateTime.utc(2026, 6, 8, 13, 15).toLocal(), + ); + }); + + test('non-UTC provider dateTime without offset keeps wall time', () { + expect( + providerDateTimeAsLocal('2026-06-08T06:02:00', 'America/Vancouver'), + DateTime(2026, 6, 8, 6, 2), + ); + }); + + test('date-only provider values are not shifted across time zones', () { + expect(providerDateTimeAsLocal('2026-06-04', 'UTC'), DateTime(2026, 6, 4)); + expect(providerDateTimeIsInstant('2026-06-04', 'UTC'), isFalse); + }); +} diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index 332821a..78adb98 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -20,7 +20,7 @@ void main() { await database.close(); }); - test('opens schema version 4 and creates required indexes', () async { + test('opens schema version 5 and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') .getSingle(); @@ -31,7 +31,7 @@ void main() { ) .get(); - expect(version.data['user_version'], 4); + expect(version.data['user_version'], 5); expect(indexes.map((row) => row.data['name']).toSet(), { 'idx_accounts_provider', 'idx_accounts_provider_account', @@ -285,7 +285,7 @@ void main() { .getSingle(); final op = await database.pendingOpsDao.getOp('op-1'); - expect(version.data['user_version'], 4); + expect(version.data['user_version'], 5); expect(op, isNot(equals(null))); expect(op!.baselineRawJson, equals(null)); diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 32f1c8f..4b59eea 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -123,6 +123,102 @@ void main() { expect(find.text('End date/time'), findsNothing); }); + testWidgets('event time popup opens with current time and requires a value', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning', allDay: false), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + await tester.ensureVisible(find.text('Start time')); + await tester.tap(find.text('Start time')); + await tester.pumpAndSettle(); + + final fieldFinder = _timeTextEntryFinder(); + final entry = tester.widget(fieldFinder); + expect(entry.controller?.text, '09:00'); + expect( + tester + .widgetList(find.byType(EditableText)) + .any((entry) => entry.controller.text.contains('09:00')), + isTrue, + ); + + await tester.enterText(fieldFinder, ''); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect(tester.widget(fieldFinder).controller?.text, isEmpty); + }); + + testWidgets('event time popup accepts midnight input', (tester) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning', allDay: false), + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + await tester.ensureVisible(find.text('Start time')); + await tester.tap(find.text('Start time')); + await tester.pumpAndSettle(); + await tester.enterText(_timeTextEntryFinder(), '00'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.start?.year, 2026); + expect(saved?.start?.month, 6); + expect(saved?.start?.day, 8); + expect(saved?.start?.hour, 0); + expect(saved?.start?.minute, 0); + expect(saved?.end?.hour, 10); + }); + + test('event draft requires end after start', () { + final draft = EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 10), + end: DateTime.utc(2026, 6, 8, 9), + ); + + expect(draft.canSave, isFalse); + }); + testWidgets('event editor does not show metadata fields', (tester) async { await tester.pumpWidget( localizedTestApp( @@ -372,6 +468,73 @@ void main() { expect(find.text('5 minutes before'), findsOneWidget); }); + testWidgets('Microsoft event categories can be selected from suggestions', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + categories: const ['Home'], + ), + sources: _microsoftSources, + categorySuggestionsByAccount: const { + 'microsoft-account': ['Home', 'Work'], + }, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ); + + await tester.ensureVisible(find.text('Add category')); + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.text('Add category')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('event-category-input')), 'wo'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Work').last); + await tester.pumpAndSettle(); + await tester.tap(_headerButtonFinder('Save')); + + expect(saved?.categories, ['Home', 'Work']); + }); + + testWidgets('Google event editor does not show categories', (tester) async { + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ); + + expect(find.text('Categories'), findsNothing); + }); + testWidgets( 'removing a Microsoft event reminder disables provider reminder', (tester) async { @@ -428,7 +591,7 @@ void main() { dialogs, contains('barrierColor: busyMaxModalBarrierColor(context)'), ); - expect(editor, contains('SingleChildScrollView')); + expect(dialogs, contains('BusyMaxModalEditorSurface')); expect(workspace, contains('showBusyMaxEventEditorDialog')); expect(workspace, isNot(contains('ScheduleEditorOverlay'))); }); @@ -439,7 +602,10 @@ void main() { ).readAsStringSync(); final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); - expect(editor, contains('BusyMaxEditorHeader(')); + expect(editor, contains('BusyMaxModalEditorScaffold(')); + expect(design, contains('class BusyMaxModalEditorScaffold')); + expect(design, contains('BusyMaxEditorHeader(')); + expect(design, contains('SingleChildScrollView')); expect(design, contains('BusyMaxHeaderPushButton.outlined')); expect(design, contains('BusyMaxHeaderPushButton.filled')); expect( @@ -564,6 +730,7 @@ void main() { expect(editor, contains('textAlign: TextAlign.end')); expect(editor, contains('class _CalendarSourceDot')); expect(editor, contains('source.backgroundColor')); + expect(editor, contains('ScheduleProjection.deterministicSourceColor')); expect(editor, isNot(contains('SourcePicker('))); expect(editor, isNot(contains('labelText: l10n.calendar'))); }, @@ -574,7 +741,7 @@ void main() { 'lib/src/features/calendar/presentation/event_editor.dart', ).readAsStringSync(); - final modeIndex = editor.indexOf('_EventTimeModeRow('); + final modeIndex = editor.indexOf('BusyMaxTimeModeRow('); final startDateIndex = editor.indexOf('label: l10n.startDate'); final startTimeIndex = editor.indexOf('label: l10n.startTime'); final endDateIndex = editor.indexOf('label: l10n.endDate'); @@ -673,6 +840,12 @@ Finder _headerButtonFinder(String label) { .first; } +Finder _timeTextEntryFinder() { + return find.byWidgetPredicate( + (widget) => widget is TextFormField && widget.controller != null, + ); +} + Finder _plainTextFinder(String label) { return find.byWidgetPredicate( (widget) => widget is Text && widget.data == label, diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 48f5dcf..35fa515 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -59,6 +59,90 @@ void main() { expect(backend.notifications.single.summary, 'Tareas que vencen hoy'); }); + + test('reminder notification details are visible by default', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + ); + + await service.notifyTaskReminder('Pay rent', 'Due at 9:00 AM'); + + expect(backend.notifications.single.summary, 'Pay rent'); + expect(backend.notifications.single.body, 'Due at 9:00 AM'); + }); + + test('reminder notifications are not transient', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + ); + + await service.notifyEventReminder('Standup', 'Starts at 9:00 AM'); + + expect( + backend.notifications.single.hints.map((hint) => hint.key), + isNot(contains('transient')), + ); + }); + + test('reminder notification default action activates callback', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + ); + var activated = false; + + await service.notifyEventReminder( + 'Standup', + 'Starts at 9:00', + onActivated: () async => activated = true, + ); + await backend.notifications.single.onAction?.call('default'); + + expect(backend.notifications.single.actions.single.key, 'default'); + expect(activated, isTrue); + }); + + test('private reminder notifications hide item details', () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith( + notificationDetailLevel: NotificationDetailLevel.private, + ), + ); + + await service.notifyEventReminder('Doctor', 'Clinic'); + + expect(backend.notifications.single.summary, 'Event reminder'); + expect( + backend.notifications.single.body, + 'Details are hidden by privacy settings.', + ); + }); + + test( + 'detailed notification switch overrides private reminder text', + () async { + final backend = _FakeNotificationBackend(); + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith( + detailedNotifications: true, + notificationDetailLevel: NotificationDetailLevel.private, + ), + ); + + await service.notifyEventReminder('Doctor', 'Clinic'); + + expect(backend.notifications.single.summary, 'Doctor'); + expect(backend.notifications.single.body, 'Clinic'); + }, + ); } class _FakeNotificationBackend implements DesktopNotificationBackend { @@ -69,8 +153,12 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { String summary, { String body = '', List hints = const [], + List actions = const [], + DesktopNotificationActionHandler? onAction, }) async { - notifications.add(_NotificationRecord(summary, body)); + notifications.add( + _NotificationRecord(summary, body, hints, actions, onAction), + ); } @override @@ -78,8 +166,17 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { } class _NotificationRecord { - const _NotificationRecord(this.summary, this.body); + const _NotificationRecord( + this.summary, + this.body, + this.hints, + this.actions, + this.onAction, + ); final String summary; final String body; + final List hints; + final List actions; + final DesktopNotificationActionHandler? onAction; } diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index 2867a73..79849eb 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:drift/drift.dart'; @@ -58,6 +59,58 @@ void main() { ); }); + test( + 'Google event default reminder schedules from calendar source', + () async { + await _upsertEvent( + database, + accountId: 'google:g', + provider: TaskProvider.google, + remindersJson: {'useDefault': true}, + sourceRawJson: { + 'id': 'cal-1', + 'defaultReminders': [ + {'method': 'popup', 'minutes': 15}, + ], + }, + ); + + await service.rebuildUpcomingEventNotifications('google:g'); + + final rows = await database.select(database.notificationSchedule).get(); + expect(rows.single.sourceType, 'event'); + expect( + rows.single.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 8, 45).millisecondsSinceEpoch, + ); + }, + ); + + test( + 'Google event explicit empty reminder ignores calendar defaults', + () async { + await _upsertEvent( + database, + accountId: 'google:g', + provider: TaskProvider.google, + remindersJson: {'useDefault': false, 'overrides': const []}, + sourceRawJson: { + 'id': 'cal-1', + 'defaultReminders': [ + {'method': 'popup', 'minutes': 15}, + ], + }, + ); + + await service.rebuildUpcomingEventNotifications('google:g'); + + expect( + await database.select(database.notificationSchedule).get(), + isEmpty, + ); + }, + ); + test('Microsoft event reminder schedules notification', () async { await _upsertEvent( database, @@ -75,6 +128,125 @@ void main() { ); }); + test('Microsoft UTC event reminder uses event timezone', () async { + await _upsertEvent( + database, + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + startDateTime: '2026-06-08T09:00:00', + startTimeZone: 'UTC', + remindersJson: {'isReminderOn': true, 'reminderMinutesBeforeStart': 30}, + ); + + await service.rebuildUpcomingEventNotifications('microsoft:m'); + + final rows = await database.select(database.notificationSchedule).get(); + expect( + rows.single.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 8, 30).millisecondsSinceEpoch, + ); + }); + + test('Microsoft local event reminder keeps local wall time', () async { + await _upsertEvent( + database, + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + startDateTime: '2026-06-08T09:00:00', + startTimeZone: 'America/Vancouver', + remindersJson: {'isReminderOn': true, 'reminderMinutesBeforeStart': 30}, + ); + + await service.rebuildUpcomingEventNotifications('microsoft:m'); + + final rows = await database.select(database.notificationSchedule).get(); + expect( + rows.single.scheduledAtUtc, + DateTime(2026, 6, 8, 8, 30).toUtc().millisecondsSinceEpoch, + ); + }); + + test('missed event reminder before event start fires immediately', () async { + service = NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 6, 8, 4, 21, 30), + ); + await _upsertEvent( + database, + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + startDateTime: '2026-06-08T04:26:00.000Z', + remindersJson: {'isReminderOn': true, 'reminderMinutesBeforeStart': 5}, + ); + + await service.rebuildUpcomingEventNotifications('microsoft:m'); + + final rows = await database.select(database.notificationSchedule).get(); + expect( + rows.single.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 4, 21, 30).millisecondsSinceEpoch, + ); + }); + + test('missed event reminder after event start is not scheduled', () async { + service = NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 6, 8, 4, 26, 30), + ); + await _upsertEvent( + database, + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + startDateTime: '2026-06-08T04:26:00.000Z', + remindersJson: {'isReminderOn': true, 'reminderMinutesBeforeStart': 5}, + ); + + await service.rebuildUpcomingEventNotifications('microsoft:m'); + + expect(await database.select(database.notificationSchedule).get(), isEmpty); + }); + + test('local Microsoft event reminder wakes notification scheduler', () async { + var schedulerCalls = 0; + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 8, 8), + onNotificationScheduleChanged: () async => schedulerCalls += 1, + ); + await repository.upsertSource( + accountId: 'microsoft:m', + source: const CalendarSourceDto( + provider: TaskProvider.microsoft, + providerCalendarId: 'cal-1', + summary: 'Calendar', + ), + ); + + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'microsoft:m', + sourceId: 'microsoft:m|microsoft|cal-1', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith( + title: 'Standup', + reminders: const { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 15, + }, + ), + ); + + final rows = await database.select(database.notificationSchedule).get(); + expect(schedulerCalls, 1); + expect(rows.single.sourceType, 'event'); + expect( + rows.single.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 8, 45).millisecondsSinceEpoch, + ); + }); + test('deleted event removes scheduled notification', () async { await _upsertEvent( database, @@ -111,6 +283,27 @@ void main() { expect(await database.select(database.notificationSchedule).get(), isEmpty); }); + + test( + 'Microsoft UTC task reminder schedules from provider timezone', + () async { + await _insertTaskReminder( + database, + status: 'needsAction', + reminderDateTime: '2026-06-08T13:15:00', + reminderTimeZone: 'UTC', + ); + + await service.rebuildUpcomingTaskNotifications('microsoft:m'); + + final rows = await database.select(database.notificationSchedule).get(); + expect(rows.single.sourceType, 'task'); + expect( + rows.single.scheduledAtUtc, + DateTime.utc(2026, 6, 8, 13, 15).millisecondsSinceEpoch, + ); + }, + ); } Future _insertAccount( @@ -137,6 +330,9 @@ Future _upsertEvent( required String accountId, required TaskProvider provider, required Object remindersJson, + String startDateTime = '2026-06-08T09:00:00.000Z', + String? startTimeZone, + Map sourceRawJson = const {}, }) async { final repository = CalendarRepository(database: database); await repository.upsertSource( @@ -145,6 +341,7 @@ Future _upsertEvent( provider: provider, providerCalendarId: 'cal-1', summary: 'Calendar', + rawJson: sourceRawJson, ), ); await repository.upsertEvent( @@ -154,7 +351,8 @@ Future _upsertEvent( providerCalendarId: 'cal-1', providerEventId: 'event-1', title: 'Standup', - startDateTime: '2026-06-08T09:00:00.000Z', + startDateTime: startDateTime, + startTimeZone: startTimeZone, endDateTime: '2026-06-08T10:00:00.000Z', remindersJson: remindersJson, rawJson: {'id': 'event-1', 'subject': 'Standup'}, @@ -165,6 +363,8 @@ Future _upsertEvent( Future _insertTaskReminder( AppDatabase database, { required String status, + String reminderDateTime = '2026-06-08T09:15:00.000Z', + String? reminderTimeZone, }) async { await database .into(database.taskLists) @@ -188,7 +388,8 @@ Future _insertTaskReminder( title: 'File report', status: Value(status), microsoftIsReminderOn: const Value(true), - microsoftReminderDateTime: const Value('2026-06-08T09:15:00.000Z'), + microsoftReminderDateTime: Value(reminderDateTime), + microsoftReminderTimeZone: Value(reminderTimeZone), rawJson: jsonEncode({'id': 'task-1'}), createdLocalAtUtc: '2026-06-08T00:00:00.000Z', updatedLocalAtUtc: '2026-06-08T00:00:00.000Z', diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart new file mode 100644 index 0000000..19dafab --- /dev/null +++ b/test/features/notifications/notification_scheduler_test.dart @@ -0,0 +1,194 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; +import 'package:busymax/src/features/notifications/notification_scheduler.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:desktop_notifications/desktop_notifications.dart'; +import 'package:drift/drift.dart' hide isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late _FakeNotificationBackend backend; + late NotificationScheduler scheduler; + late DateTime now; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + backend = _FakeNotificationBackend(); + now = DateTime.utc(2026, 6, 8, 9); + scheduler = NotificationScheduler( + database: database, + notifications: DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + ), + interval: const Duration(days: 1), + nowUtc: () => now, + ); + + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft:m', + provider: Value(TaskProvider.microsoft.storageValue), + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + }); + + tearDown(() async { + scheduler.stop(); + await database.close(); + }); + + test('notifies when a due reminder is scheduled after startup', () async { + scheduler.start(); + + await database + .into(database.notificationSchedule) + .insert( + NotificationScheduleCompanion.insert( + id: 'task|microsoft:m|list-1|task-1', + accountId: 'microsoft:m', + sourceType: 'task', + sourceId: 'task-1', + scheduledAtUtc: DateTime.utc(2026, 6, 8, 9).millisecondsSinceEpoch, + title: 'File report', + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + + await _waitUntil(() => backend.notifications.isNotEmpty); + + expect(backend.notifications.single.summary, 'File report'); + final rows = await database.select(database.notificationSchedule).get(); + expect(rows.single.sentAtUtc, isNotNull); + }); + + test('notifies at the next due time without waiting for polling', () async { + final startedAt = DateTime.now(); + final baseNow = now; + scheduler.stop(); + scheduler = NotificationScheduler( + database: database, + notifications: DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + ), + interval: const Duration(days: 1), + nowUtc: () => baseNow.add(DateTime.now().difference(startedAt)), + ); + scheduler.start(); + + await database + .into(database.notificationSchedule) + .insert( + NotificationScheduleCompanion.insert( + id: 'task|microsoft:m|list-1|future-task', + accountId: 'microsoft:m', + sourceType: 'task', + sourceId: 'future-task', + scheduledAtUtc: baseNow + .add(const Duration(milliseconds: 60)) + .millisecondsSinceEpoch, + title: 'Future report', + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + + await _waitUntil(() => backend.notifications.isNotEmpty); + + expect(backend.notifications.single.summary, 'Future report'); + }); + + test('notification activation receives due schedule row', () async { + NotificationScheduleData? activatedRow; + scheduler.stop(); + scheduler = NotificationScheduler( + database: database, + notifications: DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + ), + interval: const Duration(days: 1), + nowUtc: () => now, + onNotificationActivated: (row) async => activatedRow = row, + ); + scheduler.start(); + + await database + .into(database.notificationSchedule) + .insert( + NotificationScheduleCompanion.insert( + id: 'event|event-1|5', + accountId: 'microsoft:m', + sourceType: 'event', + sourceId: 'event-1', + scheduledAtUtc: DateTime.utc(2026, 6, 8, 9).millisecondsSinceEpoch, + title: 'Standup', + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + + await _waitUntil(() => backend.notifications.isNotEmpty); + await backend.notifications.single.onAction?.call('default'); + + expect(activatedRow?.id, 'event|event-1|5'); + expect(activatedRow?.sourceType, 'event'); + expect(activatedRow?.sourceId, 'event-1'); + }); +} + +Future _waitUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 1), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for condition.'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + +class _FakeNotificationBackend implements DesktopNotificationBackend { + final notifications = <_NotificationRecord>[]; + + @override + Future notify( + String summary, { + String body = '', + List hints = const [], + List actions = const [], + DesktopNotificationActionHandler? onAction, + }) async { + notifications.add(_NotificationRecord(summary, body, actions, onAction)); + } + + @override + Future close() async {} +} + +class _NotificationRecord { + const _NotificationRecord( + this.summary, + this.body, + this.actions, + this.onAction, + ); + + final String summary; + final String body; + final List actions; + final DesktopNotificationActionHandler? onAction; +} diff --git a/test/features/schedule/application/compact_agenda_sections_test.dart b/test/features/schedule/application/compact_agenda_sections_test.dart new file mode 100644 index 0000000..36d8033 --- /dev/null +++ b/test/features/schedule/application/compact_agenda_sections_test.dart @@ -0,0 +1,218 @@ +import 'dart:io'; + +import 'package:busymax/src/features/schedule/application/compact_agenda_sections.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final today = DateTime(2026, 6, 10); + + test('overdue incomplete tasks appear in Overdue', () { + final sections = buildCompactAgendaSections( + today: today, + items: [_task('overdue', start: DateTime(2026, 6, 9))], + ); + + expect(sections, hasLength(1)); + expect(sections.single.kind, CompactAgendaSectionKind.overdue); + expect(sections.single.items.single.title, 'overdue'); + }); + + test('completed tasks are excluded before sectioning', () { + final sections = buildCompactAgendaSections( + today: today, + items: [_task('done', start: today, completed: true)], + ); + + expect(sections, isEmpty); + }); + + test('today items appear under Today day section', () { + final sections = buildCompactAgendaSections( + today: today, + items: [ + _event('standup', start: today.add(const Duration(hours: 9))), + _task('submit', start: today), + ], + ); + + expect(sections.single.kind, CompactAgendaSectionKind.day); + expect(sections.single.day, today); + expect(sections.single.items.map((item) => item.title), [ + 'submit', + 'standup', + ]); + }); + + test('tomorrow items appear under Tomorrow day section', () { + final tomorrow = today.add(const Duration(days: 1)); + final sections = buildCompactAgendaSections( + today: today, + items: [_task('tomorrow', start: tomorrow)], + ); + + expect(sections.single.day, tomorrow); + expect(sections.single.items.single.title, 'tomorrow'); + }); + + test('future items group by day', () { + final friday = today.add(const Duration(days: 2)); + final saturday = today.add(const Duration(days: 3)); + final sections = buildCompactAgendaSections( + today: today, + items: [ + _event('friday event', start: friday), + _task('saturday task', start: saturday), + ], + ); + + expect(sections.map((section) => section.day), [friday, saturday]); + expect( + sections.expand((section) => section.items).map((item) => item.title), + ['friday event', 'saturday task'], + ); + }); + + test('future items are not capped to seven days by sectioning', () { + final later = today.add(const Duration(days: 14)); + final sections = buildCompactAgendaSections( + today: today, + items: [_task('later', start: later)], + ); + + expect(sections.single.kind, CompactAgendaSectionKind.day); + expect(sections.single.day, later); + expect(sections.single.items.single.title, 'later'); + }); + + test('more than 8 overdue tasks are capped', () { + final sections = buildCompactAgendaSections( + today: today, + items: [ + for (var index = 0; index < 10; index += 1) + _task( + 'overdue $index', + start: today.subtract(Duration(days: index + 1)), + ), + ], + ); + + expect(sections.single.items, hasLength(8)); + expect(sections.single.hasMore, isTrue); + }); + + test('overdue limit can be expanded', () { + final sections = buildCompactAgendaSections( + today: today, + overdueLimit: 16, + items: [ + for (var index = 0; index < 10; index += 1) + _task( + 'overdue $index', + start: today.subtract(Duration(days: index + 1)), + ), + ], + ); + + expect(sections.single.items, hasLength(10)); + expect(sections.single.hasMore, isFalse); + }); + + test('no-date tasks are capped and can be expanded', () { + final cappedSections = buildCompactAgendaSections( + today: today, + items: [ + for (var index = 0; index < 10; index += 1) _task('someday $index'), + ], + ); + final expandedSections = buildCompactAgendaSections( + today: today, + noDateLimit: 16, + items: [ + for (var index = 0; index < 10; index += 1) _task('someday $index'), + ], + ); + + expect(cappedSections.single.items, hasLength(8)); + expect(cappedSections.single.hasMore, isTrue); + expect(expandedSections.single.items, hasLength(10)); + expect(expandedSections.single.hasMore, isFalse); + }); + + test('no-date tasks appear in No date section', () { + final sections = buildCompactAgendaSections( + today: today, + items: [_task('someday')], + ); + + expect(sections.single.kind, CompactAgendaSectionKind.noDate); + expect(sections.single.items.single.title, 'someday'); + }); + + test('no-date tasks appear after overdue and before dated sections', () { + final sections = buildCompactAgendaSections( + today: today, + items: [ + _task('dated', start: today), + _task('someday'), + _task('overdue', start: today.subtract(const Duration(days: 1))), + ], + ); + + expect(sections.map((section) => section.kind), [ + CompactAgendaSectionKind.overdue, + CompactAgendaSectionKind.noDate, + CompactAgendaSectionKind.day, + ]); + expect(sections[1].items.single.title, 'someday'); + }); + + test('compact agenda data includes no-date tasks without old events', () { + final source = File( + 'lib/src/features/schedule/application/compact_agenda_data.dart', + ).readAsStringSync(); + + expect(source, contains('showNoDateTasks: false')); + expect(source, contains('compactAgendaInitialDays = 30')); + expect(source, contains('compactAgendaPageDays = 30')); + expect(source, contains('compactAgendaDataForQueryProvider')); + expect(source, contains('repository.listOverdueTasks')); + expect(source, contains('repository.listNoDateTasks')); + expect(source, contains('limit: query.overdueLimit')); + expect(source, contains('limit: query.noDateLimit')); + }); +} + +TaskScheduleItem _task( + String title, { + DateTime? start, + bool completed = false, +}) { + return TaskScheduleItem( + id: title, + accountId: 'account', + provider: TaskProvider.google, + sourceId: 'tasks', + title: title, + completed: completed, + allDay: true, + start: start, + sourceName: 'Inbox', + ); +} + +CalendarScheduleItem _event(String title, {required DateTime start}) { + return CalendarScheduleItem( + id: title, + accountId: 'account', + provider: TaskProvider.google, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: title, + allDay: false, + start: start, + end: start.add(const Duration(hours: 1)), + sourceName: 'Work', + ); +} diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart new file mode 100644 index 0000000..1c55573 --- /dev/null +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -0,0 +1,425 @@ +import 'dart:io'; + +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; +import 'package:busymax/src/features/schedule/presentation/compact_agenda_panel.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + final today = DateTime(2026, 6, 10); + + testWidgets('signed-out state shows sign-in and open app message', ( + tester, + ) async { + await tester.pumpWidget( + _testPanel( + data: _data(today, hasSignedInAccounts: false, hasSources: false), + ), + ); + + expect(find.text('Sign in to show agenda.'), findsOneWidget); + expect(find.text('Open BusyMax'), findsWidgets); + }); + + testWidgets('no sources state shows no sources message', (tester) async { + await tester.pumpWidget(_testPanel(data: _data(today, hasSources: false))); + + expect(find.text('No visible calendars or task lists.'), findsOneWidget); + expect(find.text('Open BusyMax'), findsWidgets); + }); + + testWidgets('long error state scrolls without overflowing', (tester) async { + final message = List.filled(160, 'Failure details').join('\n'); + + await tester.pumpWidget( + _testPanel( + data: AsyncError(message, StackTrace.empty), + size: const Size(420, 520), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Agenda unavailable'), findsOneWidget); + }); + + testWidgets('empty state shows positive empty message', (tester) async { + await tester.pumpWidget(_testPanel(data: _data(today))); + + expect(find.text('Clear for now'), findsOneWidget); + expect(find.text('No events or tasks'), findsOneWidget); + }); + + test('compact agenda panel loads more days as the list is scrolled', () { + final source = File( + 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + ).readAsStringSync(); + + expect( + source, + contains('ref.watch(compactAgendaDataForQueryProvider(_query))'), + ); + expect(source, contains('_loadedDays += compactAgendaPageDays')); + expect(source, contains('metrics.extentAfter > 1')); + expect(source, contains('end: data.range.end')); + }); + + test('new task action stays inside the compact agenda window', () { + final source = File( + 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + ).readAsStringSync(); + + expect(source, contains('NewTaskEditorPanel')); + expect(source, isNot(contains('MainWindowCommandClient().newTask'))); + expect(source, contains('_creatingTask = true')); + expect(source, isNot(contains('useNativeDatePicker: false'))); + }); + + test('compact agenda uses in-window editors for task and event actions', () { + final source = File( + 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + ).readAsStringSync(); + + expect(source, contains('NewTaskEditorPanel')); + expect(source, contains('TaskDetailsPane')); + expect(source, contains('EventEditor(')); + expect(source, contains('_openTaskEditor(item)')); + expect(source, contains('_openEventEditor(item)')); + expect(source, contains('_creatingEvent = true')); + expect(source, contains('dialogBarrierColor: Colors.transparent')); + expect(source, isNot(contains('MainWindowCommandClient().newTask'))); + }); + + testWidgets('no-date tasks render in a No date section', (tester) async { + await tester.pumpWidget( + _testPanel(data: _data(today, items: [_task('Plan someday')])), + ); + + expect(find.text('No date'), findsOneWidget); + expect(find.text('Plan someday'), findsOneWidget); + }); + + testWidgets('compact shell has rounded corners', (tester) async { + await tester.pumpWidget(_testPanel(data: _data(today))); + + final clip = tester.widget(find.byType(ClipRRect).first); + + expect(clip.borderRadius, isA()); + }); + + testWidgets('header uses native close control and no open-app chrome', ( + tester, + ) async { + await tester.pumpWidget( + _testPanel( + data: _data(today, items: [_event('Team sync', start: today)]), + ), + ); + + expect(find.byType(YaruWindowControl), findsOneWidget); + expect(find.byIcon(Icons.open_in_full), findsNothing); + expect(find.text('Open BusyMax'), findsNothing); + expect(find.text('New task'), findsOneWidget); + }); + + testWidgets('more overdue row loads overdue tasks in place', (tester) async { + final overdueDay = today.subtract(const Duration(days: 1)); + final items = [ + for (var index = 0; index < 10; index += 1) + _task('Overdue task $index', start: overdueDay), + ]; + + await tester.pumpWidget( + _testPanel( + data: _data(today, items: items), + size: const Size(420, 680), + ), + ); + + expect(find.text('Load more overdue tasks'), findsOneWidget); + expect(find.text('Overdue task 8'), findsNothing); + + await tester.ensureVisible(find.text('Load more overdue tasks')); + await tester.pump(); + await tester.tap(find.text('Load more overdue tasks')); + await tester.pump(); + + expect(find.text('Load more overdue tasks'), findsNothing); + expect(find.text('Overdue task 8'), findsOneWidget); + expect(find.text('Overdue task 9'), findsOneWidget); + }); + + testWidgets('more no-date row loads no-date tasks in place', (tester) async { + final items = [ + for (var index = 0; index < 10; index += 1) _task('Someday task $index'), + ]; + + await tester.pumpWidget( + _testPanel( + data: _data(today, items: items), + size: const Size(420, 680), + ), + ); + + expect(find.text('Load more no-date tasks'), findsOneWidget); + expect(find.text('Someday task 8'), findsNothing); + + await tester.ensureVisible(find.text('Load more no-date tasks')); + await tester.pump(); + await tester.tap(find.text('Load more no-date tasks')); + await tester.pump(); + + expect(find.text('Load more no-date tasks'), findsNothing); + expect(find.text('Someday task 8'), findsOneWidget); + expect(find.text('Someday task 9'), findsOneWidget); + }); + + testWidgets('task row renders checkbox and calls completion callback', ( + tester, + ) async { + var completed = false; + final task = _task('Submit report', start: today); + + await tester.pumpWidget( + _testPanel( + data: _data(today, items: [task]), + onTaskCompletionChanged: (_, value) async { + completed = value; + }, + ), + ); + + expect(find.byType(YaruCheckbox), findsOneWidget); + await tester.tap(find.byType(YaruCheckbox)); + await tester.pump(); + + expect(completed, isTrue); + }); + + testWidgets('event row does not render checkbox', (tester) async { + await tester.pumpWidget( + _testPanel( + data: _data(today, items: [_event('Team sync', start: today)]), + ), + ); + + expect(find.text('Team sync'), findsOneWidget); + expect(find.byType(YaruCheckbox), findsNothing); + }); + + testWidgets('rows use native grouped action row pattern', (tester) async { + final event = _event('Team sync', start: today); + + await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); + + expect(find.byType(BusyMaxGroupedList), findsWidgets); + expect(find.byType(BusyMaxActionRow), findsWidgets); + expect(find.text('Team sync'), findsOneWidget); + }); + + testWidgets('chrome uses scroll shadows instead of static borders', ( + tester, + ) async { + final items = [ + for (var index = 0; index < 24; index += 1) + _event('Event $index', start: today.add(Duration(minutes: index))), + ]; + + await tester.pumpWidget( + _testPanel( + data: _data(today, items: items), + size: const Size(420, 520), + ), + ); + + final header = tester.widget( + find.byKey(const ValueKey('compactAgendaHeader')), + ); + final footer = tester.widget( + find.byKey(const ValueKey('compactAgendaFooter')), + ); + final headerDecoration = header.decoration! as BoxDecoration; + final footerDecoration = footer.decoration! as BoxDecoration; + + expect(headerDecoration.border, isNull); + expect(footerDecoration.border, isNull); + expect( + tester + .widget( + find.byKey(const ValueKey('compactAgendaTopScrollShadow')), + ) + .opacity, + 0, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('compactAgendaBottomScrollShadow')), + ) + .opacity, + 0, + ); + + await tester.drag(find.byType(ListView), const Offset(0, -180)); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const ValueKey('compactAgendaTopScrollShadow')), + ) + .opacity, + 1, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('compactAgendaBottomScrollShadow')), + ) + .opacity, + 1, + ); + }); + + testWidgets('row tap calls open-item callback', (tester) async { + ScheduleItem? opened; + final event = _event('Team sync', start: today); + + await tester.pumpWidget( + _testPanel( + data: _data(today, items: [event]), + onOpenItem: (item) async { + opened = item; + }, + ), + ); + + await tester.tap(find.text('Team sync')); + await tester.pump(); + + expect(opened, event); + }); + + testWidgets('default row tap shows details popover in compact window', ( + tester, + ) async { + final event = _event('Team sync', start: today); + + await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); + + await tester.tap(find.text('Team sync')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.download_outlined), findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + expect(find.text('Work'), findsWidgets); + }); + + testWidgets('loading state renders progress and skeleton rows', ( + tester, + ) async { + await tester.pumpWidget( + _testPanel(data: const AsyncLoading()), + ); + + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(find.byType(Container), findsWidgets); + }); + + testWidgets('startup one-pixel allocation does not overflow', (tester) async { + await tester.pumpWidget( + _testPanel(data: _data(today), size: const Size(1, 1)), + ); + + expect(tester.takeException(), isNull); + expect(find.text('Agenda'), findsNothing); + }); +} + +Widget _testPanel({ + required AsyncValue data, + Size size = const Size(420, 680), + Future Function(ScheduleItem item)? onOpenItem, + CompactAgendaTaskCompletionCallback? onTaskCompletionChanged, +}) { + return ProviderScope( + child: localizedTestApp( + child: Scaffold( + body: SizedBox( + width: size.width, + height: size.height, + child: CompactAgendaPanel( + data: data, + onOpenBusyMax: () async {}, + onNewTask: () async {}, + onRefresh: () async {}, + onHide: () async {}, + onOpenItem: onOpenItem, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + ), + ), + ), + ); +} + +AsyncValue _data( + DateTime today, { + List items = const [], + bool hasMoreOverdueTasks = false, + bool hasMoreNoDateTasks = false, + bool hasSignedInAccounts = true, + bool hasSources = true, +}) { + return AsyncData( + CompactAgendaData( + today: today, + range: ScheduleRange( + start: today, + end: today.add(const Duration(days: 30)), + ), + items: items, + hasMoreOverdueTasks: hasMoreOverdueTasks, + hasMoreNoDateTasks: hasMoreNoDateTasks, + hasSignedInAccounts: hasSignedInAccounts, + hasSources: hasSources, + generatedAt: today, + ), + ); +} + +TaskScheduleItem _task(String title, {DateTime? start}) { + return TaskScheduleItem( + id: title, + accountId: 'account', + provider: TaskProvider.google, + sourceId: 'tasks', + title: title, + completed: false, + allDay: true, + start: start, + sourceName: 'Inbox', + ); +} + +CalendarScheduleItem _event(String title, {required DateTime start}) { + return CalendarScheduleItem( + id: title, + accountId: 'account', + provider: TaskProvider.google, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: title, + allDay: false, + start: start, + end: start.add(const Duration(hours: 1)), + sourceName: 'Work', + ); +} diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index c82eb4b..4c67a17 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_event_block.dart'; @@ -36,7 +37,7 @@ void main() { items: _itemsFor(selectedDate), onDaySelected: (_) {}, onEmptySlot: (_) {}, - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -52,6 +53,61 @@ void main() { expect(find.byType(icv.EventsList), findsNothing); }); + testWidgets('day view applies configured display hours to planner scroll', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleDayWeekView( + range: ScheduleRange.day(selectedDate), + selectedDate: selectedDate, + daysShowed: 1, + dayStartMinute: 8 * 60, + dayEndMinute: 18 * 60, + items: _itemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + final planner = tester.widget( + find.byType(icv.EventsPlanner), + ); + expect(planner.initialVerticalScrollOffset, 0.9 * 8 * 60); + expect(planner.minVerticalScrollOffset, isNull); + expect(planner.maxVerticalScrollOffset, isNull); + expect(planner.offTimesParam.offTimesAllDaysRanges, hasLength(2)); + expect(planner.offTimesParam.offTimesAllDaysRanges.first.start.hour, 0); + expect(planner.offTimesParam.offTimesAllDaysRanges.first.end.hour, 8); + expect(planner.offTimesParam.offTimesAllDaysRanges.last.start.hour, 18); + expect(planner.offTimesParam.offTimesAllDaysRanges.last.end.hour, 24); + final painter = + planner.offTimesParam.offTimesAllDaysPainter!( + 0, + selectedDate, + true, + 0.9, + planner.offTimesParam.offTimesAllDaysRanges, + Theme.of( + tester.element(find.byType(ScheduleDayWeekView)), + ).colorScheme.surface, + ) + as icv.OffSetAllDaysPainter; + expect(painter.paintToday, isTrue); + }); + testWidgets('short overlapping event block does not overflow', ( tester, ) async { @@ -74,30 +130,120 @@ void main() { expect(find.text('Design review'), findsOneWidget); }); - testWidgets('event and task chips tolerate negative planner widths', ( + testWidgets('same-slot day items render in a horizontal strip', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 320, + height: 520, + child: ScheduleDayWeekView( + range: ScheduleRange.day(selectedDate), + selectedDate: selectedDate, + daysShowed: 1, + items: _sameSlotItemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeException(), isNull); + expect( + find.byWidgetPredicate( + (widget) => + widget is SingleChildScrollView && + widget.scrollDirection == Axis.horizontal, + ), + findsOneWidget, + ); + expect(find.text('Design review', skipOffstage: false), findsOneWidget); + expect(find.text('Pairing session', skipOffstage: false), findsOneWidget); + expect(find.text('Submit report', skipOffstage: false), findsOneWidget); + }); + + testWidgets('all-day panel scrolls vertically and resizes from handle', ( tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final items = _itemsFor(selectedDate); - final event = items.whereType().first; - final task = items.whereType().first; await tester.pumpWidget( localizedTestApp( child: Scaffold( - body: Column( - children: [ - ScheduleEventBlock(item: event, width: -331.6, height: 54), - ScheduleItemChip(item: task, width: -331.6, height: 54), - ], + body: SizedBox( + width: 420, + height: 520, + child: ScheduleDayWeekView( + range: ScheduleRange.day(selectedDate), + selectedDate: selectedDate, + daysShowed: 1, + items: _manyAllDayItemsFor(selectedDate), + onDaySelected: (_) {}, + onEmptySlot: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), ), ), ), ); + await tester.pump(const Duration(milliseconds: 100)); + + final allDayScroll = find.byKey(const ValueKey('schedule-all-day-scroll')); + expect(tester.takeException(), isNull); + expect(allDayScroll, findsOneWidget); + expect( + tester.widget(allDayScroll).scrollDirection, + Axis.vertical, + ); + expect(find.text('All-day task 8', skipOffstage: false), findsOneWidget); + + final handle = find.byKey(const ValueKey('schedule-all-day-resize-handle')); + expect(handle, findsOneWidget); + final before = tester.getTopLeft(handle).dy; + await tester.drag(handle, const Offset(0, 64)); + await tester.pumpAndSettle(); + final after = tester.getTopLeft(handle).dy; + expect(after, greaterThan(before + 30)); expect(tester.takeException(), isNull); }); + testWidgets( + 'event and task chips tolerate negative and tiny planner widths', + (tester) async { + final selectedDate = DateTime(2026, 1, 15); + final items = _itemsFor(selectedDate); + final event = items.whereType().first; + final task = items.whereType().first; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Column( + children: [ + ScheduleEventBlock(item: event, width: -331.6, height: 54), + ScheduleItemChip(item: task, width: -331.6, height: 54), + ScheduleItemChip(item: task, width: 33.5, height: 27), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }, + ); + test('all-day bar uses rendered range and collapses without items', () { final source = File( 'lib/src/features/schedule/presentation/schedule_day_week_view.dart', @@ -105,11 +251,16 @@ void main() { expect( source, - contains('final fullDayBarHeight = showFullDayBar ? 82.0 : 0.0;'), + contains( + 'final fullDayBarHeight = showFullDayBar ? _fullDayBarHeight : 0.0;', + ), ); expect(source, contains('fullDayEventsBarVisibility: showFullDayBar')); expect(source, contains('fullDayEventsBarHeight: fullDayBarHeight')); expect(source, contains('fullDayEventHeight: showFullDayBar ? 24 : 0')); + expect(source, contains('fullDayEventsBuilder: (events, width)')); + expect(source, contains("ValueKey('schedule-all-day-scroll')")); + expect(source, contains("ValueKey('schedule-all-day-resize-handle')")); expect(source, contains('final displayEnd = _endOfDay(')); expect(source, contains('endTime: displayEnd')); expect(source, contains('final visibleStart = _plannerStartDate(widget);')); @@ -138,7 +289,7 @@ void main() { items: _itemsFor(selectedDate), onDaySelected: (_) {}, onCreateAtDay: (_) {}, - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -151,6 +302,33 @@ void main() { expect(find.text('Submit report'), findsOneWidget); }); + testWidgets('month view avoids overflow in very short cells', (tester) async { + final selectedDate = DateTime(2026, 1, 15); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 700, + height: 120, + child: ScheduleMonthView( + range: ScheduleRange.month(selectedDate), + selectedDate: selectedDate, + firstWeekday: DateTime.monday, + items: _sameSlotItemsFor(selectedDate), + onDaySelected: (_) {}, + onCreateAtDay: (_) {}, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + testWidgets('calendar schedule chip invokes calendar item tap', ( tester, ) async { @@ -167,7 +345,7 @@ void main() { child: ScheduleItemChip( item: event, height: 34, - onTap: (_) => selectedItem = event, + onTap: (_, [_]) => selectedItem = event, ), ), ), @@ -225,6 +403,233 @@ void main() { expect(await action, ScheduleItemDetailsAction.export); }); + testWidgets('schedule item details popover shows categories', (tester) async { + final selectedDate = DateTime(2026, 1, 15); + final task = TaskScheduleItem( + id: 'task:1', + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'Submit report', + completed: false, + allDay: true, + start: selectedDate, + end: selectedDate.add(const Duration(days: 1)), + categories: const ['Home', 'Work'], + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: task, + ); + }, + child: const Text('Open details'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect(find.text('Categories: Home, Work'), findsOneWidget); + }); + + testWidgets('schedule event details popover shows reminders and categories', ( + tester, + ) async { + final event = CalendarScheduleItem( + id: 'event:1', + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + sourceId: 'calendar:primary', + providerCalendarId: 'cal-1', + title: 'Design review', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + categories: const ['Blue category', 'Work'], + reminderMinutesBeforeStart: const [10, 60], + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ); + }, + child: const Text('Open details'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect( + find.text('Reminder: 10 minutes before, 1 hour before'), + findsOneWidget, + ); + expect(find.text('Categories: Blue category, Work'), findsOneWidget); + }); + + testWidgets('schedule task details popover shows reminder', (tester) async { + final selectedDate = DateTime(2026, 1, 15); + final task = TaskScheduleItem( + id: 'task:1', + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'Submit report', + completed: false, + allDay: true, + start: selectedDate, + end: selectedDate.add(const Duration(days: 1)), + reminder: DateTime(2026, 1, 15, 8, 30), + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: task, + ); + }, + child: const Text('Open details'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Reminder:'), findsOneWidget); + expect(find.textContaining('8:30'), findsOneWidget); + }); + + testWidgets('schedule item details popover anchors near click point', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final event = _itemsFor( + selectedDate, + ).whereType().first; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + anchorPoint: const Offset(700, 120), + item: event, + ); + }, + child: const Text('Open details'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + final popover = find.byWidgetPredicate( + (widget) => + widget is PhysicalShape && + widget.elevation == BusyMaxElevation.tooltip, + ); + final topLeft = tester.getTopLeft(popover); + + expect(topLeft.dx, greaterThan(300)); + expect(topLeft.dy, greaterThan(100)); + }); + + testWidgets('schedule item details popover shown above stays near click', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final event = _itemsFor( + selectedDate, + ).whereType().first; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) { + return TextButton( + onPressed: () { + showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + anchorPoint: const Offset(700, 540), + item: event, + ); + }, + child: const Text('Open details'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + + final popover = find.byWidgetPredicate( + (widget) => + widget is PhysicalShape && + widget.elevation == BusyMaxElevation.tooltip, + ); + final rect = tester.getRect(popover); + + expect(rect.bottom, greaterThan(500)); + expect(rect.bottom, lessThan(545)); + }); + testWidgets('schedule item details popover closes from empty space', ( tester, ) async { @@ -321,6 +726,7 @@ void main() { testWidgets('agenda view is custom and keeps no-date tasks', (tester) async { final selectedDate = DateTime(2026, 1, 15); + final yesterday = selectedDate.subtract(const Duration(days: 1)); await tester.pumpWidget( localizedTestApp( @@ -329,8 +735,55 @@ void main() { width: 1000, height: 720, child: ScheduleAgendaView( - range: ScheduleRange.week(selectedDate), + range: ScheduleRange( + start: selectedDate, + end: selectedDate.add(const Duration(days: 7)), + ), items: [ + CalendarScheduleItem( + id: 'event:past', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'Yesterday event', + allDay: false, + start: DateTime( + yesterday.year, + yesterday.month, + yesterday.day, + 9, + ), + end: DateTime( + yesterday.year, + yesterday.month, + yesterday.day, + 10, + ), + sourceName: 'Work', + ), + TaskScheduleItem( + id: 'task:overdue', + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'Pay invoice', + completed: false, + allDay: true, + start: yesterday, + sourceName: 'Inbox', + ), + TaskScheduleItem( + id: 'task:completed-overdue', + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'Completed old task', + completed: true, + allDay: true, + start: yesterday, + sourceName: 'Inbox', + ), ..._itemsFor(selectedDate), const TaskScheduleItem( id: 'task:no-date', @@ -343,7 +796,7 @@ void main() { sourceName: 'Inbox', ), ], - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -352,10 +805,22 @@ void main() { ); expect(find.byType(icv.EventsList), findsNothing); + expect(find.text('Overdue'), findsOneWidget); + expect(find.text('Pay invoice'), findsOneWidget); + expect(find.text('Completed old task'), findsNothing); + expect(find.text('Yesterday event'), findsNothing); expect(find.text('Design review'), findsOneWidget); expect(find.text('Submit report'), findsOneWidget); expect(find.text('No date'), findsOneWidget); expect(find.text('Plan someday'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Overdue')).dy, + lessThan(tester.getTopLeft(find.text('No date')).dy), + ); + expect( + tester.getTopLeft(find.text('No date')).dy, + lessThan(tester.getTopLeft(find.text('Design review')).dy), + ); }); testWidgets('agenda view stays blank instead of showing empty-state card', ( @@ -372,7 +837,7 @@ void main() { child: ScheduleAgendaView( range: ScheduleRange.week(selectedDate), items: const [], - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -385,6 +850,143 @@ void main() { expect(find.text('New task'), findsNothing); }); + testWidgets('agenda view reports row anchors for command popovers', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + final anchors = {}; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 1000, + height: 720, + child: ScheduleAgendaView( + range: ScheduleRange.week(selectedDate), + items: _itemsFor(selectedDate), + onItemSelected: (_, _, [_]) {}, + onItemAnchorAvailable: (item, context) { + anchors[item.id] = context; + }, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(anchors.keys, containsAll(['event:1', 'task:1'])); + final renderObject = anchors['event:1']!.findRenderObject(); + expect(renderObject, isA()); + expect((renderObject! as RenderBox).hasSize, isTrue); + }); + + testWidgets('agenda view asks for more items at the bottom', (tester) async { + final selectedDate = DateTime(2026, 1, 15); + var loadMoreCount = 0; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 420, + height: 360, + child: ScheduleAgendaView( + range: ScheduleRange( + start: selectedDate, + end: selectedDate.add(const Duration(days: 30)), + ), + items: [ + for (var index = 0; index < 45; index++) + TaskScheduleItem( + id: 'task:$index', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'tasks:inbox', + title: 'Task $index', + completed: false, + allDay: true, + start: selectedDate.add(Duration(days: index)), + sourceName: 'Inbox', + ), + ], + onLoadMore: () => loadMoreCount++, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -5000)); + await tester.pump(); + + expect(loadMoreCount, 1); + }); + + testWidgets('agenda view renders load-more rows for bounded buckets', ( + tester, + ) async { + final selectedDate = DateTime(2026, 1, 15); + var overdueLoads = 0; + var noDateLoads = 0; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 640, + height: 520, + child: ScheduleAgendaView( + range: ScheduleRange( + start: selectedDate, + end: selectedDate.add(const Duration(days: 30)), + ), + items: [ + TaskScheduleItem( + id: 'task:overdue', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'tasks:inbox', + title: 'Pay invoice', + completed: false, + allDay: true, + start: selectedDate.subtract(const Duration(days: 1)), + sourceName: 'Inbox', + ), + const TaskScheduleItem( + id: 'task:no-date', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'tasks:inbox', + title: 'Plan someday', + completed: false, + allDay: true, + sourceName: 'Inbox', + ), + ], + hasMoreOverdueTasks: true, + hasMoreNoDateTasks: true, + onLoadMoreOverdue: () => overdueLoads += 1, + onLoadMoreNoDate: () => noDateLoads += 1, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Load more overdue tasks')); + await tester.tap(find.text('Load more no-date tasks')); + + expect(overdueLoads, 1); + expect(noDateLoads, 1); + }); + test('schedule presentation does not use banned package final UI', () { final files = Directory( 'lib/src/features/schedule/presentation', @@ -468,6 +1070,7 @@ void main() { expect(source, contains('exportScheduleItemWithSaveDialog(item)')); expect(source, isNot(contains('exportScheduleItemToDownloads(item)'))); expect(source, contains('void _editItem(')); + expect(source, contains('reminders: _eventRemindersForEdit(')); }); test( @@ -730,7 +1333,7 @@ void main() { expect(selectedYear, DateTime(2026)); }); - test('sidebar mini calendar opens month and week modes', () { + test('sidebar mini calendar opens day, month, year, and week modes', () { final sidebar = File( 'lib/src/features/schedule/presentation/schedule_sidebar.dart', ).readAsStringSync(); @@ -751,20 +1354,34 @@ void main() { expect(sidebar, contains('onMonthSelected: onMonthSelected')); expect(sidebar, contains('onYearSelected: onYearSelected')); expect(sidebar, contains('onWeekSelected: onWeekSelected')); + expect(workspace, contains('onDateSelected: _openDay')); expect(workspace, contains('onMonthSelected: _setMonth')); expect(workspace, contains('onYearSelected: _setYear')); expect(workspace, contains('onWeekSelected: _setWeek')); + expect(workspace, contains('void _openDay(DateTime date)')); expect(workspace, contains('void _setMonth(DateTime month)')); expect(workspace, contains('void _setYear(DateTime year)')); expect(workspace, contains('void _setWeek(DateTime weekStart)')); + expect(workspace, contains('_mode = ScheduleViewMode.day')); expect(workspace, contains('_mode = ScheduleViewMode.month')); expect(workspace, contains('_mode = ScheduleViewMode.year')); expect(workspace, contains('_mode = ScheduleViewMode.week')); + expect(workspace, contains('setScheduleViewMode(ScheduleViewMode.day)')); expect(workspace, contains('setScheduleViewMode(ScheduleViewMode.month)')); expect(workspace, contains('setScheduleViewMode(ScheduleViewMode.year)')); expect(workspace, contains('setScheduleViewMode(ScheduleViewMode.week)')); }); + test('year view day clicks open day mode', () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(workspace, contains('required this.onYearDaySelected')); + expect(workspace, contains('onYearDaySelected: _openDay')); + expect(workspace, contains('onDaySelected: onYearDaySelected')); + }); + test('sidebar source rows keep visibility actions on the right', () { final sidebar = File( 'lib/src/features/schedule/presentation/schedule_sidebar.dart', @@ -782,6 +1399,8 @@ void main() { expect(sidebar, isNot(contains('? context.l10n.hideFromSchedule'))); expect(sidebar, isNot(contains(': context.l10n.showInSchedule'))); expect(sidebar, contains('minHeight: BusyMaxSizes.sidebarRowHeight')); + expect(sidebar, contains('leading: _SourceDot')); + expect(sidebar, contains('class _SourceDot')); expect(sidebar, contains('YaruIcons.checkmark')); expect(sidebar, contains('busyMaxSubtleButtonBackground(context)')); expect(sidebar, isNot(contains('YaruIcons.checkbox'))); @@ -861,6 +1480,12 @@ void main() { ).readAsStringSync(); expect(source, contains('final _plannerKey = GlobalKey')); + expect( + source, + contains( + '_controller = icv.EventsController();\n _jumpToVisibleDayStart();', + ), + ); expect(source, contains('_plannerKey.currentState?.jumpToDate(date)')); expect(source, contains('initialDate: _plannerStartDate(widget)')); }); @@ -880,7 +1505,7 @@ void main() { }, ); - test('month and agenda views support horizontal paging gestures', () { + test('month and year views support horizontal paging gestures', () { final source = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -897,12 +1522,163 @@ void main() { ); expect( source, - contains('ScheduleViewMode.agenda => _HorizontalSchedulePager'), + isNot(contains('ScheduleViewMode.agenda => _HorizontalSchedulePager')), ); + expect(source, contains('ScheduleViewMode.agenda => ScheduleAgendaView')); expect(source, contains('onPrevious: onPrevious')); expect(source, contains('onNext: onNext')); }); + test('agenda range starts at selected date and grows while scrolling', () { + final source = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final agendaSource = File( + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + ).readAsStringSync(); + + expect(source, contains('static const _agendaInitialDays = 30')); + expect(source, contains('static const _agendaPageDays = 30')); + expect(source, contains('static const _agendaInitialTaskBucketLimit = 8')); + expect(source, contains('static const _agendaTaskBucketPageSize = 8')); + expect(source, contains('var _agendaLoadedDays = _agendaInitialDays')); + expect( + source, + contains('var _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit'), + ); + expect( + source, + contains('var _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit'), + ); + expect(source, contains('ScheduleViewMode.agenda => ScheduleRange(')); + expect(source, contains('start: _day(_selectedDate)')); + expect( + source, + contains( + 'end: _day(_selectedDate).add(Duration(days: _agendaLoadedDays))', + ), + ); + expect(source, contains('void _loadMoreAgendaDays()')); + expect(source, contains('_agendaLoadedDays += _agendaPageDays')); + expect(source, contains('onLoadMore: onAgendaLoadMore')); + expect( + source, + isNot(contains('ScheduleViewMode.agenda => ScheduleRange.week')), + ); + expect(agendaSource, contains('notification.metrics.extentAfter > 1')); + expect(source, isNot(contains('subtract(const Duration(days: 30))'))); + expect(source, contains('void _loadMoreAgendaOverdueTasks()')); + expect(source, contains('void _loadMoreAgendaNoDateTasks()')); + }); + + test('agenda removes page controls from toolbar and native headerbar', () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final toolbar = File( + 'lib/src/features/schedule/presentation/schedule_toolbar.dart', + ).readAsStringSync(); + final headerService = File( + 'lib/src/platform/linux_header_bar_service.dart', + ).readAsStringSync(); + final nativeRunner = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + + expect( + toolbar, + contains('final showPaging = mode != ScheduleViewMode.agenda'), + ); + expect(toolbar, contains('if (showPaging)')); + expect( + toolbar, + contains('ScheduleViewMode.agenda => context.l10n.viewAgenda'), + ); + expect( + workspace, + contains('navigationVisible: _mode != ScheduleViewMode.agenda'), + ); + expect( + workspace, + contains( + 'service.setNavigationVisible(headerBarState.navigationVisible)', + ), + ); + expect(headerService, contains('Future setNavigationVisible')); + expect(nativeRunner, contains('set_header_navigation_visible')); + expect(nativeRunner, contains('setNavigationVisible')); + }); + + test('agenda queries bounded buckets separately from dated items', () { + final source = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(source, contains('Future<_ScheduleItemsResult> _scheduleItems')); + expect(source, contains('final currentItems = repository.listItems')); + expect( + source, + contains( + 'showNoDateTasks: searchHasQuery || _mode != ScheduleViewMode.agenda', + ), + ); + expect( + source, + contains('final overdueTasks = repository.listOverdueTasks'), + ); + expect(source, contains('before: range.start')); + expect(source, contains('limit: _agendaOverdueTaskLimit')); + expect(source, contains('final noDateTasks = repository.listNoDateTasks')); + expect(source, contains('limit: _agendaNoDateTaskLimit')); + expect(source, contains('showCompletedTasks: false')); + expect(source, contains('hasMoreOverdueTasks: overduePage.hasMore')); + expect(source, contains('hasMoreNoDateTasks: noDatePage.hasMore')); + expect(source, contains('List _agendaItems')); + expect(source, contains('if (item is CalendarScheduleItem)')); + expect( + source, + contains('return ScheduleProjection.intersects(item, range);'), + ); + expect(source, contains('if (item is TaskScheduleItem)')); + expect(source, contains('return !item.completed;')); + }); + + test('agenda task markers use task list icons, not checkbox icons', () { + final agenda = File( + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + ).readAsStringSync(); + final compactAgenda = File( + 'lib/src/features/schedule/presentation/compact_agenda_panel.dart', + ).readAsStringSync(); + + expect(agenda, contains('isTask ? YaruIcons.task_list')); + expect(compactAgenda, contains('isTask ? YaruIcons.task_list')); + expect(agenda, isNot(contains('YaruIcons.checkbox'))); + expect(compactAgenda, isNot(contains('YaruIcons.checkbox'))); + }); + + test( + 'event editor receives calendar event time zones from schedule item', + () { + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final scheduleItem = File( + 'lib/src/schedule/schedule_item.dart', + ).readAsStringSync(); + final repository = File( + 'lib/src/schedule/schedule_repository.dart', + ).readAsStringSync(); + + expect(scheduleItem, contains('final String? startTimeZone;')); + expect(scheduleItem, contains('final String? endTimeZone;')); + expect(repository, contains('startTimeZone: event.startTimeZone')); + expect(repository, contains('endTimeZone: event.endTimeZone')); + expect(workspace, contains('startTimeZone: item.startTimeZone')); + expect(workspace, contains('endTimeZone: item.endTimeZone')); + }, + ); + test('year mode uses existing schedule primitives', () { final mode = File( 'lib/src/schedule/schedule_view_mode.dart', @@ -990,3 +1766,80 @@ List _itemsFor(DateTime day) { ), ]; } + +List _sameSlotItemsFor(DateTime day) { + final start = DateTime(day.year, day.month, day.day, 9); + final end = DateTime(day.year, day.month, day.day, 10); + return [ + CalendarScheduleItem( + id: 'event:1', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'Design review', + allDay: false, + start: start, + end: end, + colorHex: '#3584e4', + sourceName: 'Work', + ), + CalendarScheduleItem( + id: 'event:2', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'Pairing session', + allDay: false, + start: start, + end: end, + colorHex: '#33d17a', + sourceName: 'Work', + ), + TaskScheduleItem( + id: 'task:1', + accountId: 'microsoft:m', + provider: TaskProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'Submit report', + completed: false, + allDay: false, + start: start, + end: end, + sourceName: 'Inbox', + ), + TaskScheduleItem( + id: 'task:2', + accountId: 'google:g', + provider: TaskProvider.google, + sourceId: 'tasks:inbox', + title: 'Review notes', + completed: false, + allDay: false, + start: start, + end: end, + sourceName: 'Inbox', + ), + ]; +} + +List _manyAllDayItemsFor(DateTime day) { + final start = DateTime(day.year, day.month, day.day); + final end = start.add(const Duration(days: 1)); + return [ + for (var index = 0; index < 8; index++) + TaskScheduleItem( + id: 'all-day-task:$index', + accountId: index.isEven ? 'google:g' : 'microsoft:m', + provider: index.isEven ? TaskProvider.google : TaskProvider.microsoft, + sourceId: 'tasks:inbox', + title: 'All-day task ${index + 1}', + completed: false, + allDay: true, + start: start, + end: end, + sourceName: 'Inbox', + ), + ]; +} diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 029ef81..3e325df 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -109,6 +109,11 @@ void main() { allDay: true, startDateTime: '2026-06-11T00:00:00.0000000', endDateTime: '2026-06-12T00:00:00.0000000', + remindersJson: { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 30, + }, + categoriesJson: ['Holiday', 'Company'], ), ); @@ -126,6 +131,154 @@ void main() { expect(event.allDay, isTrue); expect(event.start, DateTime(2026, 6, 11)); expect(event.end, DateTime(2026, 6, 12)); + expect(event.reminderMinutesBeforeStart, [30]); + expect(event.categories, ['Holiday', 'Company']); + }, + ); + + test('Microsoft timed calendar event keeps time zones for editing', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.microsoft); + final calendarRepository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 9), + ); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: TaskProvider.microsoft, + providerCalendarId: 'calendar', + summary: 'Work', + ), + ); + await calendarRepository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: TaskProvider.microsoft, + providerCalendarId: 'calendar', + providerEventId: 'event', + title: 'Planning', + startDateTime: '2026-06-11T04:20:00.0000000', + startTimeZone: 'Pacific Standard Time', + endDateTime: '2026-06-11T04:50:00.0000000', + endTimeZone: 'Pacific Standard Time', + ), + ); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + + expect(items, hasLength(1)); + final event = items.single as CalendarScheduleItem; + expect(event.start, DateTime(2026, 6, 11, 4, 20)); + expect(event.end, DateTime(2026, 6, 11, 4, 50)); + expect(event.startTimeZone, 'Pacific Standard Time'); + expect(event.endTimeZone, 'Pacific Standard Time'); + }); + + test( + 'Google timed calendar event keeps provider wall time for editing', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.google); + final calendarRepository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 9), + ); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: TaskProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + ), + ); + await calendarRepository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: TaskProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'event', + title: 'Planning', + startDateTime: '2026-06-11T05:52:00-07:00', + startTimeZone: 'America/Vancouver', + endDateTime: '2026-06-11T06:52:00-07:00', + endTimeZone: 'America/Vancouver', + ), + ); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + + expect(items, hasLength(1)); + final event = items.single as CalendarScheduleItem; + expect(event.start, DateTime(2026, 6, 11, 5, 52)); + expect(event.end, DateTime(2026, 6, 11, 6, 52)); + expect(event.startTimeZone, 'America/Vancouver'); + expect(event.endTimeZone, 'America/Vancouver'); + }, + ); + + test( + 'Google calendar event default reminders appear on schedule item', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.google); + final calendarRepository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 9), + ); + await calendarRepository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: TaskProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + rawJson: { + 'id': 'calendar', + 'defaultReminders': [ + {'method': 'popup', 'minutes': 15}, + ], + }, + ), + ); + await calendarRepository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: TaskProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'event', + title: 'Planning', + startDateTime: '2026-06-11T09:00:00', + endDateTime: '2026-06-11T10:00:00', + remindersJson: {'useDefault': true}, + ), + ); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 11)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeTasks: false, + ), + ); + + expect(items, hasLength(1)); + final event = items.single as CalendarScheduleItem; + expect(event.reminderMinutesBeforeStart, [15]); }, ); @@ -175,6 +328,221 @@ void main() { expect(task.allDay, isFalse); expect(dueDayItems, isEmpty); }); + + test('Microsoft task with midnight due appears as timed slot', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.microsoft); + await _insertTaskList(database); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'inbox', + id: 'ms-all-day-task', + title: 'File expenses', + status: const Value('needsAction'), + dueUtc: const Value('2026-06-12'), + microsoftDueDateTime: const Value('2026-06-12T00:00:00'), + microsoftIsReminderOn: const Value(true), + microsoftReminderDateTime: const Value('2026-06-12T08:30:00'), + categoriesJson: const Value('["Expenses","Work"]'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeCalendarEvents: false, + ), + ); + + expect(items, hasLength(1)); + final task = items.single as TaskScheduleItem; + expect(task.title, 'File expenses'); + expect(task.allDay, isFalse); + expect(task.start, DateTime(2026, 6, 12)); + expect(task.end, DateTime(2026, 6, 12, 0, 30)); + expect(task.reminder, DateTime(2026, 6, 12, 8, 30)); + expect(task.categories, ['Expenses', 'Work']); + }); + + test('Microsoft UTC task reminder appears as local display time', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.microsoft); + await _insertTaskList(database); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'inbox', + id: 'ms-utc-reminder-task', + title: 'File expenses', + status: const Value('needsAction'), + dueUtc: const Value('2026-06-12'), + microsoftDueDateTime: const Value('2026-06-12'), + microsoftIsReminderOn: const Value(true), + microsoftReminderDateTime: const Value('2026-06-12T13:02:00'), + microsoftReminderTimeZone: const Value('UTC'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeCalendarEvents: false, + ), + ); + + final task = items.single as TaskScheduleItem; + expect(task.reminder, DateTime.utc(2026, 6, 12, 13, 2).toLocal()); + }); + + test('Microsoft task with date-only due appears as all-day', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.microsoft); + await _insertTaskList(database); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'inbox', + id: 'ms-all-day-task', + title: 'File expenses', + status: const Value('needsAction'), + dueUtc: const Value('2026-06-12'), + microsoftDueDateTime: const Value('2026-06-12'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeCalendarEvents: false, + ), + ); + + expect(items, hasLength(1)); + final task = items.single as TaskScheduleItem; + expect(task.title, 'File expenses'); + expect(task.allDay, isTrue); + expect(task.start, DateTime(2026, 6, 12)); + expect(task.end, DateTime(2026, 6, 13)); + }); + + test('repository limits no-date task bucket', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.google); + await _insertTaskList(database); + for (var index = 0; index < 10; index += 1) { + await _insertTask( + database, + id: 'no-date-$index', + title: 'Someday $index', + ); + } + + final repository = ScheduleRepository(database); + final firstPage = await repository.listNoDateTasks( + limit: 8, + filters: const ScheduleFilters( + accountIds: {'account'}, + taskListFilterActive: true, + taskListIds: {'inbox'}, + ), + ); + final expandedPage = await repository.listNoDateTasks( + limit: 12, + filters: const ScheduleFilters( + accountIds: {'account'}, + taskListFilterActive: true, + taskListIds: {'inbox'}, + ), + ); + + expect(firstPage.items, hasLength(8)); + expect(firstPage.hasMore, isTrue); + expect(firstPage.items.every((item) => item.start == null), isTrue); + expect(expandedPage.items, hasLength(10)); + expect(expandedPage.hasMore, isFalse); + }); + + test('repository limits overdue task bucket', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: TaskProvider.google); + await _insertTaskList(database); + for (var index = 0; index < 10; index += 1) { + final due = DateTime(2026, 6, 9).subtract(Duration(days: index)); + await _insertTask( + database, + id: 'overdue-$index', + title: 'Overdue $index', + dueUtc: _dateOnly(due), + ); + } + await _insertTask( + database, + id: 'today', + title: 'Today', + dueUtc: '2026-06-10', + ); + await _insertTask(database, id: 'no-date', title: 'Someday'); + + final repository = ScheduleRepository(database); + final firstPage = await repository.listOverdueTasks( + before: DateTime(2026, 6, 10), + limit: 8, + filters: const ScheduleFilters( + accountIds: {'account'}, + taskListFilterActive: true, + taskListIds: {'inbox'}, + ), + ); + final expandedPage = await repository.listOverdueTasks( + before: DateTime(2026, 6, 10), + limit: 12, + filters: const ScheduleFilters( + accountIds: {'account'}, + taskListFilterActive: true, + taskListIds: {'inbox'}, + ), + ); + + expect(firstPage.items, hasLength(8)); + expect(firstPage.hasMore, isTrue); + expect(firstPage.items.every((item) => item.start != null), isTrue); + expect( + firstPage.items, + isNot( + contains( + predicate((item) { + return item.title == 'Today' || item.title == 'Someday'; + }), + ), + ), + ); + expect(expandedPage.items, hasLength(10)); + expect(expandedPage.hasMore, isFalse); + }); } Future _seedSearchDatabase(AppDatabase database) async { @@ -231,4 +599,35 @@ Future _insertTaskList(AppDatabase database) { ); } +Future _insertTask( + AppDatabase database, { + required String id, + required String title, + String? dueUtc, +}) { + return database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'inbox', + id: id, + title: title, + status: const Value('needsAction'), + dueUtc: Value(dueUtc), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); +} + +String _dateOnly(DateTime date) { + return [ + date.year.toString().padLeft(4, '0'), + date.month.toString().padLeft(2, '0'), + date.day.toString().padLeft(2, '0'), + ].join('-'); +} + const _now = '2026-01-01T00:00:00.000Z'; diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 8d698f4..236d5dc 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -128,6 +128,13 @@ void main() { expect(find.text('Localization'), findsNothing); expect(find.text('Sync'), findsNothing); + await tester.tap(find.text('Schedule')); + await tester.pumpAndSettle(); + + expect(find.text('Day starts at'), findsOneWidget); + expect(find.text('Day ends at'), findsOneWidget); + expect(find.text('Add Google account'), findsNothing); + await tester.tap(find.text('System')); await tester.pumpAndSettle(); @@ -178,6 +185,26 @@ void main() { await tester.pump(); }); + test('Schedule display hours persist and keep a valid range', () async { + final store = _MemorySettingsStore(); + final first = AppSettingsController(store); + await Future.delayed(Duration.zero); + + await first.setScheduleDayStartMinute(9 * 60); + await first.setScheduleDayEndMinute(18 * 60); + + final second = AppSettingsController(store); + await Future.delayed(Duration.zero); + + expect(second.state.scheduleDayStartMinute, 9 * 60); + expect(second.state.scheduleDayEndMinute, 18 * 60); + + await second.setScheduleDayStartMinute(23 * 60); + + expect(second.state.scheduleDayStartMinute, 23 * 60); + expect(second.state.scheduleDayEndMinute, 24 * 60); + }); + testWidgets('Settings creates a new list for the account card', ( tester, ) async { diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 3f51308..c07c6fd 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -73,6 +73,96 @@ void main() { }, ); + test( + 'local Google event create uses app local timezone over UTC source', + () async { + await CalendarRepository(database: database).upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: TaskProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + timeZone: 'UTC', + ), + ); + + await CalendarRepository( + database: database, + localTimeZone: 'America/Vancouver', + ).createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'account|google|cal-1', + providerCalendarId: 'cal-1', + start: DateTime(2026, 6, 8, 9), + end: DateTime(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + ); + + final event = await database.select(database.calendarEvents).getSingle(); + final op = await database.select(database.pendingOps).getSingle(); + final request = jsonDecode(op.requestJson) as Map; + + expect(event.startTimeZone, 'America/Vancouver'); + expect(event.endTimeZone, 'America/Vancouver'); + expect(request['startTimeZone'], 'America/Vancouver'); + expect(request['endTimeZone'], 'America/Vancouver'); + }, + ); + + test( + 'local Google event edit uses app local timezone over UTC event', + () async { + await CalendarRepository(database: database).upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: TaskProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + timeZone: 'UTC', + ), + ); + final eventId = await _insertEvent( + database, + providerEventId: 'provider-event', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ); + + await CalendarRepository( + database: database, + localTimeZone: 'America/Vancouver', + ).updateLocalEvent( + EventEditorDraft.existing( + eventId: eventId, + accountId: 'account', + sourceId: 'account|google|cal-1', + providerCalendarId: 'cal-1', + title: 'Patched', + allDay: false, + start: DateTime(2026, 6, 8, 9), + end: DateTime(2026, 6, 8, 10), + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + ); + + final event = await (database.select( + database.calendarEvents, + )..where((table) => table.id.equals(eventId))).getSingle(); + final op = + await (database.select(database.pendingOps) + ..where((table) => table.operationType.equals('event.patch'))) + .getSingle(); + final request = jsonDecode(op.requestJson) as Map; + + expect(event.startTimeZone, 'America/Vancouver'); + expect(event.endTimeZone, 'America/Vancouver'); + expect(request['startTimeZone'], 'America/Vancouver'); + expect(request['endTimeZone'], 'America/Vancouver'); + }, + ); + test( 'blocked Google create with missing time zone is replayed with source zone', () async { @@ -144,6 +234,9 @@ void main() { 'getEvent:cal-1:provider-event', 'updateEvent:cal-1:provider-event:Patched', ]); + expect(client.updatedMutations.single.title, 'Patched'); + expect(client.updatedMutations.single.startTimeZone, 'America/Vancouver'); + expect(client.updatedMutations.single.endTimeZone, 'America/Vancouver'); expect( await database.pendingOpsDao.pendingOpsForReplay('account', _later), isEmpty, @@ -328,18 +421,21 @@ void main() { database, providerEventId: 'provider-event', ); + var schedulerCalls = 0; await CalendarSyncEngine( database: database, client: client, accountId: 'account', nowUtc: () => DateTime.utc(2026, 6, 8), + onNotificationScheduleChanged: () async => schedulerCalls += 1, ).fullSync(); final row = await (database.select( database.calendarEvents, )..where((table) => table.id.equals(eventId))).getSingle(); expect(row.isDeleted, isTrue); + expect(schedulerCalls, 1); }); test('full refresh does not remove pending local dirty event', () async { @@ -411,6 +507,8 @@ Future _insertAccount(AppDatabase database) { Future _insertEvent( AppDatabase database, { required String providerEventId, + String? startTimeZone, + String? endTimeZone, }) async { final event = CalendarEventDto( provider: TaskProvider.google, @@ -418,7 +516,9 @@ Future _insertEvent( providerEventId: providerEventId, title: 'Base', startDateTime: '2026-06-08T09:00:00.000Z', + startTimeZone: startTimeZone, endDateTime: '2026-06-08T10:00:00.000Z', + endTimeZone: endTimeZone, updatedAtServer: '2026-06-08T00:00:00.000Z', rawJson: { 'id': providerEventId, @@ -473,6 +573,7 @@ final _later = DateTime.utc(2026, 6, 9); class _FakeCalendarClient implements CloudCalendarClient { final calls = []; final createdMutations = []; + final updatedMutations = []; int _createdCount = 0; GoogleCalendarApiError? deleteError; @@ -518,7 +619,13 @@ class _FakeCalendarClient implements CloudCalendarClient { required CalendarEventMutation mutation, }) async { calls.add('updateEvent:$calendarId:$eventId:${mutation.title}'); - return _event(eventId, title: mutation.title ?? ''); + updatedMutations.add(mutation); + return _event( + eventId, + title: mutation.title ?? '', + startTimeZone: mutation.startTimeZone, + endTimeZone: mutation.endTimeZone, + ); } @override diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index 36a376a..dc61506 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -60,6 +60,57 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('createTask writes and queues extended task fields', () async { + await repository.createTask( + 'list-1', + const TaskCreateInput( + title: 'Task', + fields: { + 'title': 'Task', + 'microsoftDueDateTime': { + 'dateTime': '2026-06-05T09:30:00', + 'timeZone': 'America/Vancouver', + }, + 'microsoftDueTimeZone': 'America/Vancouver', + 'microsoftReminderDateTime': { + 'dateTime': '2026-06-05T08:30:00', + 'timeZone': 'America/Vancouver', + }, + 'microsoftReminderTimeZone': 'America/Vancouver', + 'microsoftIsReminderOn': true, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': {'type': 'noEnd', 'startDate': '2026-06-05'}, + }, + 'importance': 'high', + 'categories': ['Work'], + }, + ), + ); + + final tasks = await database.tasksDao.listTasks('account', 'list-1'); + final ops = await database.pendingOpsDao.pendingOpsForReplay( + 'account', + DateTime.utc(2026, 6, 4, 1), + ); + final body = (jsonDecode(ops.single.requestJson) as Map)['body'] as Map; + + expect(tasks.single.microsoftDueDateTime, '2026-06-05T09:30:00'); + expect(tasks.single.microsoftDueTimeZone, 'America/Vancouver'); + expect(tasks.single.microsoftReminderDateTime, '2026-06-05T08:30:00'); + expect(tasks.single.microsoftReminderTimeZone, 'America/Vancouver'); + expect(tasks.single.microsoftIsReminderOn, isTrue); + expect(tasks.single.importance, 'high'); + expect(jsonDecode(tasks.single.categoriesJson!), ['Work']); + expect(body['microsoftDueDateTime'], { + 'dateTime': '2026-06-05T09:30:00', + 'timeZone': 'America/Vancouver', + }); + expect(body['microsoftIsReminderOn'], isTrue); + expect(body['importance'], 'high'); + expect(body['categories'], ['Work']); + }); + test('patchTask updates local fields and queues patch op', () async { var mutationQueuedCalls = 0; repository = _repository( @@ -96,6 +147,44 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('patchTask rebuilds task reminders and notifies scheduler', () async { + var schedulerCalls = 0; + repository = _repository( + database, + onNotificationScheduleChanged: () async => schedulerCalls += 1, + ); + await database.tasksDao.upsertTask( + _task( + id: 'task-1', + position: '1', + rawJson: '{"id":"task-1","title":"Original"}', + ), + ); + + await repository.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({ + 'microsoftIsReminderOn': true, + 'microsoftReminderDateTime': { + 'dateTime': '2026-06-05T08:30:00', + 'timeZone': 'America/Vancouver', + }, + 'microsoftReminderTimeZone': 'America/Vancouver', + }), + ); + + final rows = await database.select(database.notificationSchedule).get(); + + expect(schedulerCalls, 1); + expect(rows.single.sourceType, 'task'); + expect(rows.single.title, 'task-1'); + expect( + rows.single.scheduledAtUtc, + DateTime(2026, 6, 5, 8, 30).toUtc().millisecondsSinceEpoch, + ); + }); + test('task mutations request sync after queuing pending ops', () async { var mutationQueuedCalls = 0; repository = _repository( @@ -203,11 +292,13 @@ void main() { TasksRepository _repository( AppDatabase database, { void Function()? onMutationQueued, + Future Function()? onNotificationScheduleChanged, }) { return TasksRepository( database: database, accountId: 'account', onMutationQueued: onMutationQueued, + onNotificationScheduleChanged: onNotificationScheduleChanged, nowUtc: () => DateTime.utc(2026, 6, 4), ); } diff --git a/test/features/tasks/presentation/task_details_draft_test.dart b/test/features/tasks/presentation/task_details_draft_test.dart new file mode 100644 index 0000000..fbf9fab --- /dev/null +++ b/test/features/tasks/presentation/task_details_draft_test.dart @@ -0,0 +1,50 @@ +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/features/tasks/presentation/task_details_draft.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Microsoft UTC reminder opens as local time without dirty draft', () { + final task = TaskEntity( + accountId: 'account', + taskListId: 'inbox', + id: 'task-1', + title: 'File report', + status: 'needsAction', + microsoftIsReminderOn: true, + microsoftReminderDateTime: '2026-06-12T13:02:00', + microsoftReminderTimeZone: 'UTC', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-06-12T00:00:00.000Z', + ); + final localReminder = DateTime.utc(2026, 6, 12, 13, 2).toLocal(); + + final draft = TaskDetailsDraft.fromTask(task, 'America/Vancouver'); + + expect(draft.microsoftReminderDate, _dateOnly(localReminder)); + expect(draft.microsoftReminderTime, _timeOnly(localReminder)); + expect(draft.microsoftReminderTimeZone, 'America/Vancouver'); + expect( + draft.toPatch( + task, + microsoftTaskProviderCapabilities, + localTimeZone: 'America/Vancouver', + ), + isEmpty, + ); + }); +} + +String _dateOnly(DateTime value) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; +} + +String _timeOnly(DateTime value) { + return '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}'; +} diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 0fb83a2..4c56795 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -25,7 +25,9 @@ const _nativePickerChannel = MethodChannel(nativeDateTimePickerChannelName); void main() { tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_nativePickerChannel, null); + .setMockMethodCallHandler(_nativePickerChannel, (_) async { + throw MissingPluginException(); + }); }); testWidgets('Task Details header shows Cancel and Save', (tester) async { @@ -488,6 +490,8 @@ void main() { expect(find.text('Due'), findsOneWidget); expect(find.text('Due date'), findsOneWidget); + expect(find.text('All Day'), findsNothing); + expect(find.text('Time Slot'), findsNothing); expect(find.text('Due time'), findsNothing); expect(find.text('Start'), findsNothing); expect(find.text('Start date'), findsNothing); @@ -578,6 +582,36 @@ void main() { expect(repository.patches.single.fields['categories'], ['Work']); }); + testWidgets('Microsoft category suggestions can be selected', (tester) async { + final repository = _FakeTasksRepository( + categorySuggestions: const ['Home', 'Work'], + ); + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + ); + + await tester.tap(find.text('Add category')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('task-category-input')), 'wo'); + await tester.pumpAndSettle(); + final input = find.byKey(const Key('task-category-input')); + final field = tester.widget(input); + expect(field.decoration?.border, InputBorder.none); + expect(field.decoration?.focusedBorder, InputBorder.none); + expect( + tester.getTopLeft(find.text('Work').last).dy, + greaterThanOrEqualTo(tester.getBottomLeft(input).dy - 1), + ); + await tester.tap(find.text('Work').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(repository.patches.single.fields['categories'], ['Home', 'Work']); + }); + testWidgets('Due group appears before separate Start group', (tester) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); @@ -704,8 +738,67 @@ void main() { expect(find.text('Jun 15, 2026'), findsOneWidget); }); + testWidgets('date value row can use in-window picker', (tester) async { + String? changed; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: '2026-06-06', + useNativePicker: false, + onChanged: (date) => changed = date, + ), + ), + ), + ); + + await _openRowMenu(tester, 'Due date'); + + expect(find.byType(YaruDateTimeEntry), findsOneWidget); + expect(find.byType(CalendarDatePicker), findsNothing); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(changed, '2026-06-06'); + expect(tester.takeException(), isNull); + }); + + testWidgets('in-window date picker opens empty date on today', ( + tester, + ) async { + String? changed; + final now = DateTime.now(); + final today = + '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopDateValueRow( + label: 'Due date', + date: null, + useNativePicker: false, + onChanged: (date) => changed = date, + ), + ), + ), + ); + + await _openRowMenu(tester, 'Due date'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(changed, today); + expect(tester.takeException(), isNull); + }); + testWidgets( - 'due time uses Yaru time entry instead of custom picker channel', + 'due time uses in-app time entry instead of custom picker channel', (tester) async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -719,23 +812,124 @@ void main() { alwaysUse24HourFormat: false, ); - expect(find.byType(YaruTimeEntry), findsNothing); + expect(_timeTextEntryFinder(), findsNothing); await _openRowMenu(tester, 'Due time'); - expect(find.byType(YaruTimeEntry), findsOneWidget); + expect(_timeTextEntryFinder(), findsOneWidget); expect(tester.takeException(), isNull); expect(calls, isEmpty); }, ); + testWidgets('Microsoft task scheduled mode can be switched to all-day', ( + tester, + ) async { + final repository = _FakeTasksRepository(); + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + ); + + expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); + expect(find.text('All Day'), findsOneWidget); + expect(find.text('Time Slot'), findsOneWidget); + expect(find.text('Due time'), findsOneWidget); + expect(find.text('Start time'), findsOneWidget); + + await tester.tap(find.text('All Day')); + await tester.pumpAndSettle(); + + expect(find.text('Due time'), findsNothing); + expect(find.text('Start time'), findsNothing); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(repository.patches, hasLength(1)); + final fields = repository.patches.single.fields; + expect(fields.containsKey('due'), isFalse); + expect(fields['microsoftDueDateTime'], { + 'dateTime': '2026-06-06', + 'timeZone': 'America/Vancouver', + }); + expect(fields['microsoftStartDateTime'], { + 'dateTime': '2026-06-04', + 'timeZone': 'UTC', + }); + }); + + testWidgets( + 'Microsoft all-day scheduled tasks can be switched to time slot', + (tester) async { + final repository = _FakeTasksRepository( + microsoftDueDateTime: '2026-06-06', + microsoftStartDateTime: '2026-06-04', + ); + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + ); + + expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); + expect(find.text('Due time'), findsNothing); + expect(find.text('Start time'), findsNothing); + + await tester.tap(find.text('Time Slot')); + await tester.pumpAndSettle(); + + expect(find.text('Due time'), findsWidgets); + expect(find.text('Start time'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(repository.patches, hasLength(1)); + expect(repository.patches.single.fields['microsoftDueDateTime'], { + 'dateTime': '2026-06-06T09:00:00', + 'timeZone': 'America/Vancouver', + }); + expect(repository.patches.single.fields['microsoftStartDateTime'], { + 'dateTime': '2026-06-04T09:00:00', + 'timeZone': 'UTC', + }); + }, + ); + + testWidgets('Microsoft midnight task stays in time slot mode', ( + tester, + ) async { + final repository = _FakeTasksRepository( + microsoftDueDateTime: '2026-06-06T00:00:00', + microsoftStartDateTime: '2026-06-04T00:00:00', + ); + await _pumpDetails( + tester, + microsoftTaskProviderCapabilities, + repository: repository, + ); + + expect(find.byType(BusyMaxTimeModeRow), findsOneWidget); + expect(find.text('Due time'), findsOneWidget); + expect(find.text('Start time'), findsOneWidget); + expect(find.text('All Day'), findsOneWidget); + expect(find.text('Time Slot'), findsOneWidget); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(repository.patches, isEmpty); + }); + testWidgets('time entries do not show redundant internal input label', ( tester, ) async { await _pumpDetails(tester, microsoftTaskProviderCapabilities); await _openRowMenu(tester, 'Due time'); - final entryContext = tester.element(find.byType(YaruTimeEntry).first); + final entryContext = tester.element(_timeTextEntryFinder().first); final decorationTheme = Theme.of(entryContext).inputDecorationTheme; expect(decorationTheme.floatingLabelBehavior, FloatingLabelBehavior.never); @@ -744,7 +938,7 @@ void main() { }); testWidgets( - 'empty time field uses Yaru placeholder instead of None subtitle', + 'empty time field uses time placeholder instead of None subtitle', (tester) async { await tester.pumpWidget( localizedTestApp( @@ -758,18 +952,58 @@ void main() { ), ); - expect(find.text('Due time'), findsOneWidget); + expect(find.text('Due time'), findsWidgets); expect(find.text('None'), findsNothing); - expect( - tester - .widget(find.byType(YaruTimeEntry)) - .controller - ?.timeOfDay, - isNull, - ); + final entry = tester.widget(_timeTextEntryFinder()); + expect(entry.controller?.text, isEmpty); + expect(find.text('--:--'), findsOneWidget); }, ); + testWidgets('time field accepts midnight input', (tester) async { + String? changed; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: '09:30', + onChanged: (time) => changed = time, + ), + ), + ), + ); + + await tester.enterText(_timeTextEntryFinder(), '00:00'); + await tester.pump(); + + expect(changed, '00:00'); + expect(tester.takeException(), isNull); + }); + + testWidgets('time field formats compact numeric input', (tester) async { + String? changed; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: DesktopTimeField( + label: 'Due time', + time: null, + onChanged: (time) => changed = time, + ), + ), + ), + ); + + await tester.enterText(_timeTextEntryFinder(), '0517'); + await tester.pump(); + + final entry = tester.widget(_timeTextEntryFinder()); + expect(entry.controller?.text, '05:17'); + expect(changed, '05:17'); + expect(tester.takeException(), isNull); + }); + testWidgets('Microsoft payload still includes time zone on Save', ( tester, ) async { @@ -1000,16 +1234,28 @@ Future _openRowMenu(WidgetTester tester, String label) async { await tester.pumpAndSettle(); } +Finder _timeTextEntryFinder() { + return find.byWidgetPredicate( + (widget) => widget is TextFormField && widget.controller != null, + ); +} + class _FakeTasksRepository implements TasksRepository { _FakeTasksRepository({ this.accountId = 'microsoft:m', this.reminderOn = false, this.missingTask = false, + this.microsoftDueDateTime = '2026-06-06T14:30:00', + this.microsoftStartDateTime = '2026-06-04T07:00:00.0000000', + this.categorySuggestions = const [], }); final String accountId; final bool reminderOn; final bool missingTask; + final String microsoftDueDateTime; + final String? microsoftStartDateTime; + final List categorySuggestions; final List patches = []; final List moves = []; var deleteCalls = 0; @@ -1032,19 +1278,24 @@ class _FakeTasksRepository implements TasksRepository { updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', status: 'needsAction', dueUtc: '2026-06-06', - microsoftDueDateTime: '2026-06-06T14:30:00', + microsoftDueDateTime: microsoftDueDateTime, microsoftDueTimeZone: 'America/Vancouver', - microsoftStartDateTime: '2026-06-04T07:00:00.0000000', + microsoftStartDateTime: microsoftStartDateTime, microsoftStartTimeZone: 'UTC', microsoftIsReminderOn: reminderOn, microsoftReminderDateTime: reminderOn ? '2026-06-05T09:15:00' : null, - microsoftReminderTimeZone: 'UTC', + microsoftReminderTimeZone: 'America/Vancouver', importance: 'high', categoriesJson: '["Home"]', ), ); } + @override + Stream> watchCategorySuggestions() { + return Stream.value(categorySuggestions); + } + @override Future patchTask( String taskListId, @@ -1096,6 +1347,11 @@ class _SwitchingTasksRepository implements TasksRepository { .stream; } + @override + Stream> watchCategorySuggestions() { + return Stream.value(const []); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/features/tasks/presentation/tasks_selection_state_test.dart b/test/features/tasks/presentation/tasks_selection_state_test.dart index 2657d05..8dcba6d 100644 --- a/test/features/tasks/presentation/tasks_selection_state_test.dart +++ b/test/features/tasks/presentation/tasks_selection_state_test.dart @@ -512,6 +512,11 @@ class _FakeTasksRepository implements TasksRepository { ]); } + @override + Stream> watchCategorySuggestions() { + return Stream.value(const []); + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/features/tasks/presentation/tasks_workspace_test.dart b/test/features/tasks/presentation/tasks_workspace_test.dart index 78fb166..b10bfc1 100644 --- a/test/features/tasks/presentation/tasks_workspace_test.dart +++ b/test/features/tasks/presentation/tasks_workspace_test.dart @@ -1348,6 +1348,21 @@ ProviderContainer _workspaceContainerWithAccountSync( tasksRepositoryProvider.overrideWithValue( TasksRepository(database: database, accountId: 'google:g'), ), + tasksRepositoryForAccountProvider.overrideWith((ref, accountId) { + final apiClient = accountId.startsWith('microsoft:') + ? ref.watch( + microsoftAsGoogleTasksApiClientForAccountProvider(accountId), + ) + : ref.watch(googleTasksApiClientForAccountProvider(accountId)); + return TasksRepository( + database: database, + accountId: accountId, + apiClient: apiClient, + onMutationQueued: ref + .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) + .request, + ); + }), syncEngineProvider.overrideWithValue(null), syncEngineForAccountFactoryProvider.overrideWithValue((accountId) { return syncEngines[accountId]!; @@ -1710,5 +1725,7 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { String summary, { String body = '', List hints = const [], + List actions = const [], + DesktopNotificationActionHandler? onAction, }) async {} } diff --git a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart index 9d6f379..e7ab627 100644 --- a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart @@ -57,6 +57,20 @@ void main() { expect(event.rawJson['body'], {'contentType': 'html', 'content': html}); }); + test('Microsoft event maps categories for sync storage', () { + final event = microsoftCalendarEventFromJson('cal-1', { + 'id': 'event-1', + 'subject': 'Planning', + 'categories': ['Work', 'Blue category'], + 'isAllDay': false, + 'start': {'dateTime': '2026-06-10T09:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-10T10:00:00', 'timeZone': 'UTC'}, + }); + + expect(event.categoriesJson, ['Work', 'Blue category']); + expect(event.colorId, 'Work'); + }); + test('formatted Microsoft HTML is converted to plain text with ranges', () { final document = htmlCalendarDescriptionDocument( '
Hello bold and italic
', diff --git a/test/microsoft_todo/api/microsoft_todo_google_tasks_adapter_test.dart b/test/microsoft_todo/api/microsoft_todo_google_tasks_adapter_test.dart index a92a174..caa8cf7 100644 --- a/test/microsoft_todo/api/microsoft_todo_google_tasks_adapter_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_google_tasks_adapter_test.dart @@ -34,7 +34,7 @@ void main() { 'contentType': 'html', }); expect(client.createdTaskBody['dueDateTime'], { - 'dateTime': '2026-06-06T00:00:00', + 'dateTime': '2026-06-06', 'timeZone': 'America/Vancouver', }); expect(client.createdTaskBody['status'], 'completed'); diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index dff6b64..36e031d 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,6 +42,85 @@ void main() { test('application id uses Busystack reverse DNS id', () { expect(busyMaxApplicationId, 'io.busystack.busymax'); }); + + test('Linux desktop identity matches the displayed BusyMax window', () { + final desktop = File( + 'linux/io.busystack.busymax.desktop', + ).readAsStringSync(); + final cmake = File('linux/CMakeLists.txt').readAsStringSync(); + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + + expect(desktop, contains('Name=BusyMax')); + expect(desktop, contains('Icon=io.busystack.busymax')); + expect(desktop, contains('StartupWMClass=io.busystack.busymax')); + expect( + cmake, + contains(r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.desktop"'), + ); + expect( + cmake, + contains( + r'"${CMAKE_CURRENT_SOURCE_DIR}/io.busystack.busymax.metainfo.xml"', + ), + ); + expect( + cmake, + contains( + r'"${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.svg"', + ), + ); + expect(cmake, contains('share/icons/hicolor/scalable/apps')); + expect(cmake, contains('RENAME "io.busystack.busymax.svg"')); + const pngExtension = 'png'; + expect(cmake, isNot(contains('.$pngExtension'))); + final iconFileName = + '${busyMaxApplicationId.split('.').last}.$pngExtension'; + for (final size in [64, 128, 256]) { + final path = [ + 'linux', + 'runner', + 'resources', + 'icons', + '${size}x$size', + 'apps', + iconFileName, + ].join(Platform.pathSeparator); + expect(File(path).existsSync(), isFalse); + } + expect( + runner, + contains( + 'gtk_window_set_wmclass(window, APPLICATION_ID, APPLICATION_ID);', + ), + ); + expect( + runner, + contains( + 'if (application_icon == nullptr) {\n' + ' gtk_window_set_icon_name(window, APPLICATION_ID);\n' + ' }', + ), + ); + }); + + test('agenda action no longer opens the main window', () { + final source = File( + 'lib/src/platform/busymax_tray_service.dart', + ).readAsStringSync(); + + expect(source, contains('Future _showAgenda()')); + expect(source, contains('return _onOpenAgenda();')); + expect( + source, + isNot( + contains( + 'await _windowService.showWindow();\n' + ' await _onOpenAgenda();', + ), + ), + ); + expect(source, contains('await _onBeforeQuit?.call();')); + }); } const _labels = BusyMaxTrayLabels( diff --git a/test/platform/busymax_window_args_test.dart b/test/platform/busymax_window_args_test.dart new file mode 100644 index 0000000..81ca72b --- /dev/null +++ b/test/platform/busymax_window_args_test.dart @@ -0,0 +1,58 @@ +import 'package:busymax/src/platform/busymax_window_args.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('empty string parses as main window', () { + expect(BusyMaxWindowArgs.parse('').kind, BusyMaxWindowKind.main); + }); + + test('malformed JSON parses as main window', () { + expect(BusyMaxWindowArgs.parse('{bad').kind, BusyMaxWindowKind.main); + }); + + test('compact agenda JSON parses as compact agenda window', () { + final args = BusyMaxWindowArgs.parse( + BusyMaxWindowArgs.compactAgenda.encode(), + ); + + expect(args.kind, BusyMaxWindowKind.compactAgenda); + expect(args.version, BusyMaxWindowArgs.currentVersion); + }); + + test('compact agenda JSON preserves requested position', () { + final args = BusyMaxWindowArgs.parse( + BusyMaxWindowArgs.compactAgendaAt(x: 123, y: 45).encode(), + ); + + expect(args.kind, BusyMaxWindowKind.compactAgenda); + expect(args.requestedPositionX, 123); + expect(args.requestedPositionY, 45); + }); + + test('compact agenda JSON ignores malformed requested position', () { + final args = BusyMaxWindowArgs.parse( + '{"app":"BusyMax","version":1,"kind":"compactAgenda",' + '"position":{"x":"bad","y":45}}', + ); + + expect(args.kind, BusyMaxWindowKind.compactAgenda); + expect(args.requestedPositionX, isNull); + expect(args.requestedPositionY, isNull); + }); + + test('unknown app parses as main window', () { + final args = BusyMaxWindowArgs.parse( + '{"app":"Other","version":1,"kind":"compactAgenda"}', + ); + + expect(args.kind, BusyMaxWindowKind.main); + }); + + test('unknown kind parses as main window', () { + final args = BusyMaxWindowArgs.parse( + '{"app":"BusyMax","version":1,"kind":"unknown"}', + ); + + expect(args.kind, BusyMaxWindowKind.main); + }); +} diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 5d474ae..711b402 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -52,6 +52,7 @@ void main() { await service.setSidebarWidth(300); await service.setSearchActive(false); await service.setSidebarVisible(true); + await service.setNavigationVisible(false); await service.setBackVisible(false); await service.setOnboardingControls( visible: true, @@ -63,6 +64,7 @@ void main() { await service.setModalBarrierVisible(true); await service.setTheme( const BusyMaxHeaderBarTheme( + windowBackgroundColor: Color(0xFF18181B), backgroundColor: Color(0xFF1D1D20), sidebarBackgroundColor: Color(0xFF2E2E32), foregroundColor: Color(0xFFFFFFFF), @@ -93,6 +95,7 @@ void main() { 'setSidebarWidth', 'setSearchActive', 'setSidebarVisible', + 'setNavigationVisible', 'setBackVisible', 'setOnboardingControls', 'setModalBarrierVisible', @@ -109,10 +112,15 @@ void main() { expect(calls[5].arguments, containsPair('settings', 'Settings')); expect(calls[5].arguments, containsPair('aboutBusyMax', 'About BusyMax')); expect(calls[6].arguments, 300); - expect(calls[10].arguments, containsPair('visible', true)); - expect(calls[10].arguments, containsPair('canContinue', true)); - expect(calls[10].arguments, containsPair('continueLabel', 'Continue')); + expect(calls[9].arguments, false); + expect(calls[11].arguments, containsPair('visible', true)); + expect(calls[11].arguments, containsPair('canContinue', true)); + expect(calls[11].arguments, containsPair('continueLabel', 'Continue')); expect(calls.last.arguments, containsPair('backgroundColor', '#1D1D20')); + expect( + calls.last.arguments, + containsPair('windowBackgroundColor', '#18181B'), + ); expect( calls.last.arguments, containsPair('sidebarBackgroundColor', '#2E2E32'), diff --git a/test/smoke_test.dart b/test/smoke_test.dart index 88aeb55..da89cb8 100644 --- a/test/smoke_test.dart +++ b/test/smoke_test.dart @@ -3,12 +3,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_app.dart'; import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/db/app_database.dart'; void main() { testWidgets('missing OAuth client ID screen is shown', (tester) async { + final database = AppDatabase.memoryForTests(); + addTearDown(database.close); + await tester.pumpWidget( ProviderScope( overrides: [ + databaseProvider.overrideWithValue(database), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), buildConfigProvider.overrideWithValue( const BuildConfig( googleOAuthClientId: '', @@ -46,3 +52,17 @@ void main() { expect(find.textContaining('sync task.'), findsNothing); }); } + +class _MemorySettingsStore implements LocalSettingsStore { + Map json = {}; + + @override + Future> load() async { + return Map.from(json); + } + + @override + Future save(Map json) async { + this.json = Map.from(json); + } +}