From 76885f0255685cde03b11b9fdd8826d3ff577837 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 17:44:33 -0700 Subject: [PATCH 01/53] Add categories support to tasks and events, update schema to version 5 --- lib/src/app/busymax_design.dart | 413 ++++++++++++++ .../calendar_providers/calendar_sync_dto.dart | 2 + lib/src/db/app_database.g.dart | 77 +++ lib/src/db/migrations.dart | 17 +- lib/src/db/tables.dart | 1 + .../calendar/data/calendar_repository.dart | 12 +- .../calendar/presentation/event_editor.dart | 517 ++++++++---------- .../presentation/schedule_day_week_view.dart | 437 ++++++++++++++- .../schedule_item_details_popover.dart | 14 +- .../presentation/schedule_task_chip.dart | 66 ++- .../presentation/schedule_workspace.dart | 30 +- .../features/tasks/data/tasks_repository.dart | 47 ++ .../tasks/presentation/new_task_dialog.dart | 265 ++++++--- .../presentation/task_details_draft.dart | 28 +- .../presentation/task_details_editor.dart | 322 ++++------- .../tasks/presentation/task_details_pane.dart | 51 +- .../tasks/presentation/tasks_workspace.dart | 49 +- .../microsoft_calendar_mapper.dart | 1 + .../microsoft_todo_google_tasks_adapter.dart | 2 +- lib/src/schedule/schedule_item.dart | 7 + lib/src/schedule/schedule_repository.dart | 46 +- test/app/native_ui_audit_test.dart | 4 +- test/db/app_database_test.dart | 6 +- .../presentation/event_editor_test.dart | 75 ++- .../presentation/schedule_views_test.dart | 234 +++++++- .../schedule/schedule_search_test.dart | 38 ++ .../presentation/task_details_pane_test.dart | 118 +++- .../tasks_selection_state_test.dart | 5 + .../microsoft_calendar_mapper_test.dart | 14 + ...rosoft_todo_google_tasks_adapter_test.dart | 2 +- 30 files changed, 2200 insertions(+), 700 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 0d5aee3..abf4746 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -831,6 +831,284 @@ class BusyMaxActionRow extends StatelessWidget { } } +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, + inputKey: inputKey, + onSubmitted: onSubmitted, + onCancel: onCancelAdding, + ), + for (final suggestion in visibleSuggestions) + _BusyMaxCategorySuggestionChip( + label: suggestion, + onPressed: () => onSubmitted(suggestion), + ), + ] 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), + 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 _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 _BusyMaxCategorySuggestionChip extends StatelessWidget { + const _BusyMaxCategorySuggestionChip({ + required this.label, + required this.onPressed, + }); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ActionChip( + avatar: Icon( + YaruIcons.plus, + size: BusyMaxSizes.iconSm, + color: colorScheme.onSurfaceVariant, + ), + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + labelStyle: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: Colors.transparent, + onPressed: onPressed, + ); + } +} + +class _BusyMaxCategoryInputChip extends StatelessWidget { + const _BusyMaxCategoryInputChip({ + required this.controller, + required this.hintText, + this.inputKey, + required this.onSubmitted, + required this.onCancel, + }); + + final TextEditingController controller; + final String hintText; + final Key? inputKey; + 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: inputKey, + 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, + ), + ), + ], + ), + ), + ), + ); + } +} + class BusyMaxCalendarValueRow extends StatelessWidget { const BusyMaxCalendarValueRow({ super.key, @@ -1394,6 +1672,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/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/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..58af36d 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -269,6 +269,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), @@ -388,6 +389,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), @@ -477,6 +479,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), @@ -670,7 +673,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, @@ -727,6 +730,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..8e61e90 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -3,7 +3,6 @@ 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'; @@ -19,6 +18,7 @@ Future showBusyMaxEventEditorDialog( required List sources, LinuxHeaderBarService? headerBarService, bool allowDelete = true, + Map> categorySuggestionsByAccount = const {}, }) async { return showBusyMaxModalEditorDialog( context, @@ -29,6 +29,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 +66,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 +83,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 +97,7 @@ class _EventEditorState extends State { @override void dispose() { _guestController.dispose(); + _categoryController.dispose(); super.dispose(); } @@ -111,212 +117,188 @@ 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: (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(), + ), + 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 +368,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 +526,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 +629,36 @@ 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(() { - _draft = _draft.copyWith( - reminders: reminders ?? _disabledRemindersFor(provider), - ); + _addingCategory = false; + _draft = _draft.copyWith(categories: [..._draft.categories, category]); }); } -} - -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 _removeCategory(String category) { + setState(() { + _draft = _draft.copyWith( + categories: [ + for (final value in _draft.categories) + if (value != category) value, ], - ), - ); + ); + }); } -} - -class _EventTimeModeButton extends StatelessWidget { - const _EventTimeModeButton({ - 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: _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), + ); + }); } } 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..3955b27 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -1,14 +1,24 @@ +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/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'; +const _fullDayBarDefaultHeight = 82.0; +const _fullDayBarMinHeight = 82.0; +const _fullDayBarMaxHeight = 260.0; +const _allDayResizeHandleHeight = 22.0; +const _timesIndicatorsWidth = 64.0; + class ScheduleDayWeekView extends StatefulWidget { const ScheduleDayWeekView({ super.key, @@ -39,6 +49,7 @@ class ScheduleDayWeekView extends StatefulWidget { class _ScheduleDayWeekViewState extends State { late final icv.EventsController _controller; final _plannerKey = GlobalKey(); + var _fullDayBarHeight = _fullDayBarDefaultHeight; @override void initState() { @@ -78,9 +89,10 @@ 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; - return icv.EventsPlanner( + final planner = icv.EventsPlanner( key: _plannerKey, controller: _controller, initialDate: _plannerStartDate(widget), @@ -121,7 +133,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(); @@ -160,6 +195,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(); @@ -177,7 +223,7 @@ class _ScheduleDayWeekViewState extends State { }, ), timesIndicatorsParam: icv.TimesIndicatorsParam( - timesIndicatorsWidth: 64, + timesIndicatorsWidth: _timesIndicatorsWidth, timesIndicatorsHorizontalPadding: 6, timesIndicatorsCustomPainter: (heightPerMinute) => icv.HoursPainter( heightPerMinute: heightPerMinute, @@ -218,13 +264,47 @@ class _ScheduleDayWeekViewState extends State { pinchToZoomMaxHeightPerMinute: 1.6, ), ); + 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); @@ -304,9 +384,330 @@ 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 void Function(BuildContext context, ScheduleItem item) 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 void Function(BuildContext context, ScheduleItem item) 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) => onItemSelected(context, item), + 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 void Function(BuildContext context, ScheduleItem item) 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) => + widget.onItemSelected(context, widget.items[index]), + 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 +755,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_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index 88e6016..426d175 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -330,7 +330,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 +427,17 @@ 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.categories.isNotEmpty) + _ScheduleDetailRow( + icon: Icons.sell_outlined, + text: '${context.l10n.categories}: ${item.categories.join(', ')}', + ), if (item.descriptionHtml != null && isHtmlContentType(item.descriptionContentType)) _ScheduleDetailRichRow( @@ -452,6 +457,11 @@ 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.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), ]; diff --git a/lib/src/features/schedule/presentation/schedule_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index 65c142f..679b9d9 100644 --- a/lib/src/features/schedule/presentation/schedule_task_chip.dart +++ b/lib/src/features/schedule/presentation/schedule_task_chip.dart @@ -45,6 +45,13 @@ 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; return Tooltip( message: '${item.title}\n$details', @@ -59,7 +66,7 @@ class ScheduleTaskChip extends StatelessWidget { onTap: onTap == null ? null : () => onTap!(context), child: Container( padding: EdgeInsets.symmetric( - horizontal: compact ? 5 : 8, + horizontal: horizontalPadding, vertical: compact ? 1 : 5, ), decoration: BoxDecoration( @@ -67,35 +74,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_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 5f7f4e1..8af12e5 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -63,6 +63,7 @@ class _ScheduleWorkspaceState extends ConsumerState { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(); var _latestCanShowSidebar = false; + var _latestItems = const []; ScheduleViewMode? _lastSettingsMode; _HeaderBarStateSnapshot? _lastHeaderBarState; @@ -169,6 +170,7 @@ class _ScheduleWorkspaceState extends ConsumerState { snapshot.data ?? const [], _scope, ); + _latestItems = items; final miniCalendarItemsFuture = ref .watch(scheduleRepositoryProvider) .listItems( @@ -765,6 +767,7 @@ class _ScheduleWorkspaceState extends ConsumerState { description: item.description, descriptionContentType: item.descriptionContentType, descriptionHtml: item.descriptionHtml, + categories: item.categories, ), sources, ), @@ -858,6 +861,7 @@ class _ScheduleWorkspaceState extends ConsumerState { context, initialDraft: draft, sources: sources, + categorySuggestionsByAccount: _categorySuggestionsByAccount(), headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (!mounted || result == null) { @@ -898,6 +902,7 @@ class _ScheduleWorkspaceState extends ConsumerState { initialAccountId: ref.read(activeAccountProvider), initialListId: null, initialDueUtc: due, + headerBarService: ref.read(linuxHeaderBarServiceProvider), ); if (draft == null) { return; @@ -906,10 +911,33 @@ class _ScheduleWorkspaceState extends ConsumerState { .read(tasksRepositoryForAccountProvider(draft.accountId)) .createTask( draft.taskListId, - TaskCreateInput(title: draft.title, dueUtc: draft.dueUtc), + TaskCreateInput( + title: draft.title, + dueUtc: draft.dueUtc, + categories: draft.categories, + ), ); } + 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 { final fields = { 'status': completed ? 'completed' : 'needsAction', diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index 179ab25..e8a3946 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -173,6 +173,7 @@ class TaskCreateInput { this.notes, this.status, this.dueUtc, + this.categories = const [], this.parentTaskId, this.previousSiblingTaskId, }); @@ -181,6 +182,7 @@ class TaskCreateInput { final String? notes; final String? status; final DateTime? dueUtc; + final List categories; final String? parentTaskId; final String? previousSiblingTaskId; } @@ -321,10 +323,32 @@ 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 categories = [ + for (final category in input.categories) + if (category.trim().isNotEmpty) category.trim(), + ]; await _database.transaction(() async { await _database.tasksDao.upsertTask( TasksCompanion.insert( @@ -335,6 +359,9 @@ class TasksRepository { notes: Value(input.notes), status: Value(input.status ?? 'needsAction'), dueUtc: Value(due), + categoriesJson: categories.isEmpty + ? const Value.absent() + : Value(_jsonOrNull(categories)), parent: Value(input.parentTaskId), rawJson: jsonEncode({'id': localId, 'title': input.title}), localDirty: const Value(true), @@ -354,6 +381,7 @@ class TasksRepository { if (input.notes != null) 'notes': input.notes, if (input.status != null) 'status': input.status, if (input.dueUtc != null) 'due': encodeGoogleDueDate(input.dueUtc!), + if (categories.isNotEmpty) 'categories': categories, }, if (input.parentTaskId != null) 'parent': input.parentTaskId, if (input.previousSiblingTaskId != null) @@ -746,6 +774,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/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index b90847b..ab1897f 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -5,7 +5,10 @@ import 'package:yaru/yaru.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.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'; @@ -15,12 +18,14 @@ class NewTaskDraft { required this.accountId, required this.taskListId, this.dueUtc, + this.categories = const [], }); final String title; final String accountId; final String taskListId; final DateTime? dueUtc; + final List categories; } Future showBusyMaxNewTaskDialog( @@ -30,9 +35,13 @@ Future showBusyMaxNewTaskDialog( required String? initialAccountId, required String? initialListId, DateTime? initialDueUtc, + LinuxHeaderBarService? headerBarService, }) { - return showDialog( - context: context, + return showBusyMaxModalEditorDialog( + context, + headerBarService: headerBarService, + maxWidth: 460, + maxHeight: 560, builder: (dialogContext) => UncontrolledProviderScope( container: ProviderScope.containerOf(context), child: _NewTaskDialog( @@ -63,9 +72,12 @@ class _NewTaskDialog extends ConsumerStatefulWidget { } class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { + final _categoryController = TextEditingController(); var _title = ''; String? _accountId; String? _taskListId; + var _addingCategory = false; + var _categories = const []; @override void initState() { @@ -77,6 +89,12 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { _taskListId = widget.initialListId; } + @override + void dispose() { + _categoryController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -84,96 +102,144 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { final repository = ref.watch( taskListsRepositoryForAccountProvider(accountId), ); + final tasksRepository = ref.watch( + tasksRepositoryForAccountProvider(accountId), + ); + final account = _accountForId(accountId); + final capabilities = capabilitiesForProvider( + account?.provider ?? TaskProvider.google, + ); - 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; - }); - }, - onEditingComplete: () => _submit(effectiveListId), - ), - BusyMaxGroupedList( + return StreamBuilder>( + stream: tasksRepository.watchCategorySuggestions(), + builder: (context, categorySnapshot) { + final categorySuggestions = categorySnapshot.data ?? const []; + 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 BusyMaxModalEditorScaffold( + title: l10n.newTask, + cancelLabel: l10n.cancel, + saveLabel: l10n.create, + onCancel: () => Navigator.of(context).pop(), + onSave: canCreate ? () => _submit(effectiveListId) : null, + contentMaxWidth: 460, 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; - } + ValidatedFormField( + autofocus: true, + labelText: l10n.title, + onChanged: (value) { setState(() { - _accountId = value; - _taskListId = null; + _title = value; }); }, + 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 (!capabilitiesForProvider( + _accountForId(value)?.provider ?? + TaskProvider.google, + ).supportsCategories) { + _categories = const []; + _addingCategory = false; + _categoryController.clear(); + } + }); + }, + ), + 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 (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!), + ), + ], ), - ], - ), - 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!), + if (capabilities.supportsCategories) + BusyMaxGroupedList( + title: l10n.organizationSection, + children: [ + BusyMaxCategoryEditorRow( + title: l10n.categories, + addLabel: l10n.addCategory, + categories: _categories, + suggestions: categorySuggestions, + adding: _addingCategory, + controller: _categoryController, + inputKey: const Key('new-task-category-input'), + onAddPressed: () { + setState(() { + _addingCategory = true; + }); + }, + onSubmitted: _addCategory, + onCancelAdding: () { + _categoryController.clear(); + setState(() { + _addingCategory = false; + }); + }, + onDeleted: _removeCategory, + ), + ], ), - ], - ), - ], + const SizedBox(height: BusyMaxSpacing.lg), + ], + ); + }, ); }, ); @@ -189,6 +255,36 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { return taskLists.firstWhere((list) => list.id == taskListId).title; } + AccountEntity? _accountForId(String accountId) { + for (final account in widget.accounts) { + if (account.id == accountId) { + return account; + } + } + return null; + } + + void _addCategory(String value) { + final category = value.trim(); + if (category.isEmpty || _categories.contains(category)) { + return; + } + _categoryController.clear(); + setState(() { + _addingCategory = false; + _categories = [..._categories, category]; + }); + } + + void _removeCategory(String category) { + setState(() { + _categories = [ + for (final value in _categories) + if (value != category) value, + ]; + }); + } + void _submit(String? effectiveListId) { final title = _title.trim(); final accountId = _accountId; @@ -201,6 +297,7 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { accountId: accountId, taskListId: effectiveListId, dueUtc: widget.initialDueUtc, + categories: _categories, ), ); } diff --git a/lib/src/features/tasks/presentation/task_details_draft.dart b/lib/src/features/tasks/presentation/task_details_draft.dart index 4d30ece..7530db8 100644 --- a/lib/src/features/tasks/presentation/task_details_draft.dart +++ b/lib/src/features/tasks/presentation/task_details_draft.dart @@ -32,10 +32,10 @@ class TaskDetailsDraft { title: task.title, notes: task.notes ?? '', dueDate: _dateOnly(task.dueUtc), - microsoftDueTime: _timePart(task.microsoftDueDateTime), + microsoftDueTime: _scheduleTimePart(task.microsoftDueDateTime), microsoftDueTimeZone: task.microsoftDueTimeZone ?? localTimeZone, microsoftStartDate: _datePart(task.microsoftStartDateTime), - microsoftStartTime: _timePart(task.microsoftStartDateTime), + microsoftStartTime: _scheduleTimePart(task.microsoftStartDateTime), microsoftStartTimeZone: task.microsoftStartTimeZone ?? localTimeZone, microsoftReminderEnabled: task.microsoftIsReminderOn ?? false, microsoftReminderDate: _datePart(task.microsoftReminderDateTime), @@ -117,7 +117,7 @@ class TaskDetailsDraft { fields['due'] = dueDate; } if (capabilities.supportsDueTime) { - final originalDueTime = _timePart(original.microsoftDueDateTime); + final originalDueTime = _scheduleTimePart(original.microsoftDueDateTime); final originalDueZone = original.microsoftDueTimeZone ?? localTimeZone; final dueTimeChanged = microsoftDueTime != originalDueTime; final dueZoneChanged = microsoftDueTimeZone != originalDueZone; @@ -128,7 +128,7 @@ class TaskDetailsDraft { } else { fields['microsoftDueDateTime'] = _graphDateTime( date, - microsoftDueTime ?? '00:00', + microsoftDueTime, microsoftDueTimeZone ?? localTimeZone, ); } @@ -262,7 +262,7 @@ void _putDateTimePatch( }) { final changed = date != _datePart(originalDateTime) || - time != _timePart(originalDateTime) || + time != _scheduleTimePart(originalDateTime) || timeZone != originalTimeZone; if (!changed) { return; @@ -273,7 +273,7 @@ void _putDateTimePatch( } else { fields[dateTimeField] = _graphDateTime( date ?? _todayDateOnly(), - time ?? '00:00', + time, timeZone, ); } @@ -309,8 +309,20 @@ 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? _scheduleTimePart(String? value) { + final time = _timePart(value); + return time == '00:00' ? null : time; +} + +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..1bc7182 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,7 @@ class TaskDetailsEditor extends StatefulWidget { this.onSaved, this.onTaskSwitchCancelled, this.onDirtyChanged, + this.categorySuggestions = const [], }); final TaskEntity task; @@ -53,6 +53,7 @@ class TaskDetailsEditor extends StatefulWidget { final VoidCallback? onSaved; final ValueChanged? onTaskSwitchCancelled; final ValueChanged? onDirtyChanged; + final List categorySuggestions; @override State createState() => _TaskDetailsEditorState(); @@ -115,6 +116,7 @@ class _TaskDetailsEditorState extends State { final hasChanges = _hasDraftChanges(draft); final canSave = draft.title.trim().isNotEmpty && hasChanges && !_saving; final currentList = _listTitle(draft.taskListId); + final scheduledAllDay = _isScheduledAllDay(draft); final listValue = [ currentList, widget.accountLabel, @@ -171,16 +173,30 @@ 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)), + 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 +211,7 @@ class _TaskDetailsEditorState extends State { BusyMaxGroupedList( title: l10n.startGroup, filled: true, - children: _startRows(draft), + children: _startRows(draft, scheduledAllDay), ), if (widget.capabilities.supportsReminderDateTime) BusyMaxGroupedList( @@ -324,7 +340,7 @@ class _TaskDetailsEditorState extends State { ); } - List _startRows(TaskDetailsDraft draft) { + List _startRows(TaskDetailsDraft draft, bool scheduledAllDay) { final l10n = context.l10n; return [ DesktopDateValueRow( @@ -337,16 +353,67 @@ class _TaskDetailsEditorState extends State { 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 +455,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), ); } @@ -752,177 +801,6 @@ 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}'; bool _taskChanged(TaskEntity oldTask, TaskEntity newTask) { diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 933543c..3f6510c 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -45,11 +45,14 @@ 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; var _editorDirty = false; @@ -112,6 +115,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 +130,7 @@ class _TaskDetailsPaneState extends ConsumerState { capabilities: _lastCapabilities ?? capabilities, localTimeZone: _lastLocalTimeZone ?? localTimeZone, account: _lastAccount, + categorySuggestions: _lastCategorySuggestions, ); } return const SizedBox.shrink(); @@ -148,18 +153,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, + ); + }, ); }, ); @@ -198,12 +212,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, @@ -331,6 +347,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..640fde1 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,29 @@ 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, + TaskCreateInput(title: draft.title, categories: draft.categories), + ); } 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/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 3637bb4..9279e0c 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; } @@ -33,6 +34,7 @@ class CalendarScheduleItem implements ScheduleItem { this.descriptionContentType, this.descriptionHtml, this.colorHex, + this.categories = const [], this.sourceName, this.accountDisplayName, this.accountEmail, @@ -61,6 +63,8 @@ class CalendarScheduleItem implements ScheduleItem { final String? descriptionHtml; final String? colorHex; @override + final List categories; + @override final String? sourceName; @override final String? accountDisplayName; @@ -83,6 +87,7 @@ class TaskScheduleItem implements ScheduleItem { this.start, this.end, this.notes, + this.categories = const [], this.sourceName, this.accountDisplayName, this.accountEmail, @@ -107,6 +112,8 @@ class TaskScheduleItem implements ScheduleItem { final bool completed; final String? notes; @override + final List categories; + @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..01eed95 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -138,6 +138,7 @@ class ScheduleRepository { description: event.description, descriptionContentType: descriptionBody.contentType, descriptionHtml: descriptionBody.html, + categories: _stringListFromJson(event.categoriesJson), colorHex: event.colorHex ?? calendarSourceBackgroundColorHex( @@ -210,6 +211,7 @@ class ScheduleRepository { start: start, end: end, notes: task.notes ?? task.bodyContent, + categories: _stringListFromJson(task.categoriesJson), sourceName: list?.title, accountDisplayName: accountDisplayNames[task.accountId], accountEmail: accountEmails[task.accountId], @@ -263,8 +265,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,9 +296,25 @@ 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(_isDateOnlyOrMidnight); +} + +bool _isDateOnlyOrMidnight(String value) { + return !value.contains('T') || _isMidnightDateTime(value); +} + +bool _isMidnightDateTime(String value) { + final separatorIndex = value.indexOf('T'); + if (separatorIndex < 0 || separatorIndex + 1 >= value.length) { + return false; + } + final time = value.substring(separatorIndex + 1); + return time.length >= 5 && time.substring(0, 5) == '00:00'; } bool _intersects(ScheduleRange range, DateTime? start, DateTime? end) { @@ -333,3 +352,22 @@ DateTime? _parseDateTime(String? value) { } return DateTime.tryParse(value); } + +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 []; +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 68d50c5..d1cf95b 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -96,7 +96,9 @@ void main() { expect(settings, isNot(contains('l10n.themeFamily'))); expect(settings, contains('setBackVisible(true)')); expect(settings, contains('setSidebarVisible(true)')); - expect(newTaskDialog, contains('BusyMaxDialogShell')); + expect(newTaskDialog, contains('showBusyMaxModalEditorDialog')); + expect(newTaskDialog, contains('BusyMaxModalEditorScaffold')); + expect(newTaskDialog, isNot(contains('BusyMaxDialogShell'))); expect(dateTimeFields, contains('YaruDateTimeEntry')); expect(dateTimeFields, contains('YaruTimeEntry')); 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..0007c2e 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -372,6 +372,72 @@ 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.ensureVisible(find.text('Work')); + await tester.tap(find.text('Work')); + 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 +494,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 +505,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( @@ -574,7 +643,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'); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index c82eb4b..8c40247 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -74,30 +74,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); - 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: 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); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + 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 +195,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);')); @@ -225,6 +320,48 @@ 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 item details popover closes from empty space', ( tester, ) async { @@ -990,3 +1127,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..b918166 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -175,6 +175,44 @@ void main() { expect(task.allDay, isFalse); expect(dueDayItems, isEmpty); }); + + test('Microsoft task with midnight 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-12T00:00:00'), + 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)); + }); } Future _seedSearchDatabase(AppDatabase database) async { diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 0fb83a2..3c64577 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -488,6 +488,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 +580,26 @@ 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.tap(find.text('Work')); + 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); @@ -729,6 +751,82 @@ void main() { }, ); + 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-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'), findsNothing); + expect(find.text('Start time'), findsNothing); + + await tester.tap(find.text('Time Slot')); + await tester.pumpAndSettle(); + + expect(find.text('Due time'), findsOneWidget); + 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('time entries do not show redundant internal input label', ( tester, ) async { @@ -1005,11 +1103,17 @@ class _FakeTasksRepository implements TasksRepository { 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,9 +1136,9 @@ 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, @@ -1045,6 +1149,11 @@ class _FakeTasksRepository implements TasksRepository { ); } + @override + Stream> watchCategorySuggestions() { + return Stream.value(categorySuggestions); + } + @override Future patchTask( String taskListId, @@ -1096,6 +1205,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/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'); From 08899e2606d993c6e3e8f7084513ed22c071874e Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 17:50:09 -0700 Subject: [PATCH 02/53] Simplify date-only check in ScheduleRepository and add date range validation to EventEditorDraft --- .../presentation/event_editor_draft.dart | 6 +++++- lib/src/schedule/schedule_repository.dart | 16 ++-------------- 2 files changed, 7 insertions(+), 15 deletions(-) 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/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 01eed95..67ec42d 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -300,22 +300,10 @@ bool _taskAllDay(Task task, BusyProvider provider) { task.microsoftStartDateTime, task.microsoftDueDateTime, ].whereType().where((value) => value.isNotEmpty); - return scheduleDateTimes.isEmpty || - scheduleDateTimes.every(_isDateOnlyOrMidnight); + return scheduleDateTimes.isEmpty || scheduleDateTimes.every(_isDateOnly); } -bool _isDateOnlyOrMidnight(String value) { - return !value.contains('T') || _isMidnightDateTime(value); -} - -bool _isMidnightDateTime(String value) { - final separatorIndex = value.indexOf('T'); - if (separatorIndex < 0 || separatorIndex + 1 >= value.length) { - return false; - } - final time = value.substring(separatorIndex + 1); - return time.length >= 5 && time.substring(0, 5) == '00:00'; -} +bool _isDateOnly(String value) => !value.contains('T'); bool _intersects(ScheduleRange range, DateTime? start, DateTime? end) { if (start == null) { From 6a07332b8ce0e7652ecb66bc1de068c230a21366 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 17:52:54 -0700 Subject: [PATCH 03/53] Refactor event editor to streamline date and time handling, enforce required values, and improve state management --- .../calendar/presentation/event_editor.dart | 78 +++++++++++++------ .../desktop_date_time_fields.dart | 30 ++++--- .../presentation/event_editor_test.dart | 51 ++++++++++++ .../schedule/schedule_search_test.dart | 40 +++++++++- 4 files changed, 164 insertions(+), 35 deletions(-) diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 8e61e90..8bc44d1 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -6,6 +6,7 @@ import '../../../app/busymax_dialogs.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'; @@ -166,11 +167,7 @@ class _EventEditorState extends State { children: [ BusyMaxTimeModeRow( allDay: _draft.allDay, - onChanged: (value) { - setState(() { - _draft = _draft.copyWith(allDay: value); - }); - }, + onChanged: _setAllDay, ), ], ), @@ -181,11 +178,7 @@ class _EventEditorState extends State { label: l10n.startDate, date: _dateString(_draft.start), onChanged: (value) { - setState(() { - _draft = _draft.copyWith( - start: _withDate(_draft.start, value), - ); - }); + _setStart(_withDate(_draft.start, value)); }, emptyLabel: l10n.noneValue, ), @@ -194,13 +187,10 @@ class _EventEditorState extends State { label: l10n.startTime, time: _timeString(_draft.start), onChanged: (value) { - setState(() { - _draft = _draft.copyWith( - start: _withTime(_draft.start, value), - ); - }); + _setStart(_withTime(_draft.start, value)); }, - emptyLabel: l10n.noneValue, + emptyLabel: '--:--', + allowEmpty: false, ), ], ), @@ -211,9 +201,7 @@ class _EventEditorState extends State { label: l10n.endDate, date: _dateString(_draft.end), onChanged: (value) { - setState(() { - _draft = _draft.copyWith(end: _withDate(_draft.end, value)); - }); + _setEnd(_withDate(_draft.end, value)); }, emptyLabel: l10n.noneValue, ), @@ -222,11 +210,10 @@ class _EventEditorState extends State { label: l10n.endTime, time: _timeString(_draft.end), onChanged: (value) { - setState(() { - _draft = _draft.copyWith(end: _withTime(_draft.end, value)); - }); + _setEnd(_withTime(_draft.end, value)); }, - emptyLabel: l10n.noneValue, + emptyLabel: '--:--', + allowEmpty: false, ), ], ), @@ -652,6 +639,42 @@ class _EventEditorState extends State { }); } + 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, + ); + }); + } + + 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, + ); + }); + } + + void _setEnd(DateTime end) { + final start = _draft.start; + setState(() { + _draft = _draft.copyWith( + end: start != null && !end.isAfter(start) + ? _defaultEndFor(start, _draft.allDay) + : end, + ); + }); + } + void _setReminderMinutes(BusyProvider provider, List minutes) { final reminders = _remindersFor(provider, minutes); setState(() { @@ -733,6 +756,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(); @@ -968,7 +995,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/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 7b666bd..4670488 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -252,6 +252,7 @@ class DesktopTimeValueRow extends StatelessWidget { required this.onChanged, this.enabled = true, this.emptyLabel, + this.allowEmpty = true, }); final String label; @@ -259,6 +260,7 @@ class DesktopTimeValueRow extends StatelessWidget { final ValueChanged onChanged; final bool enabled; final String? emptyLabel; + final bool allowEmpty; @override Widget build(BuildContext context) { @@ -291,6 +293,7 @@ class DesktopTimeValueRow extends StatelessWidget { label: label, time: time, onChanged: onChanged, + allowEmpty: allowEmpty, ); }, ); @@ -302,11 +305,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,14 +319,14 @@ class _DesktopTimeValueDialog extends StatefulWidget { } class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { - late final YaruTimeEntryController _controller; + late final YaruTimeEntryController? _controller; TimeOfDay? _selected; @override void initState() { super.initState(); _selected = parseTimeOfDay(widget.time); - _controller = YaruTimeEntryController(timeOfDay: _selected); + _controller = _selected == null ? YaruTimeEntryController() : null; } @override @@ -335,12 +340,14 @@ 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: widget.allowEmpty || _selected != null + ? () { + widget.onChanged( + _selected == null ? null : encodeTimeOfDay(_selected!), + ); + Navigator.of(context).pop(); + } + : null, child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], @@ -349,11 +356,14 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { context, YaruTimeEntry( controller: _controller, + initialTimeOfDay: _controller == null ? _selected : null, force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: true, + acceptEmpty: widget.allowEmpty, clearIconSemanticLabel: widget.label, onChanged: (time) { - _selected = time; + setState(() { + _selected = time; + }); }, ), ), diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 0007c2e..9c1a6c1 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -8,6 +8,7 @@ import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ubuntu_widgets/ubuntu_widgets.dart'; +import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -123,6 +124,52 @@ 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 entry = tester.widget(find.byType(YaruTimeEntry)); + expect(entry.initialTimeOfDay, const TimeOfDay(hour: 9, minute: 0)); + expect(entry.acceptEmpty, isFalse); + }); + + 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( @@ -633,6 +680,10 @@ 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'))); }, diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index b918166..92ae720 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -176,7 +176,7 @@ void main() { expect(dueDayItems, isEmpty); }); - test('Microsoft task with midnight due appears as all-day', () async { + 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); @@ -206,6 +206,44 @@ void main() { ), ); + 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)); + }); + + 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'); From 3c4ed868fddc60ad708e985cdb7798bb053e5f37 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 17:58:38 -0700 Subject: [PATCH 04/53] Allow midnight as a valid task time and ensure midnight tasks remain in time slot mode --- .../presentation/task_details_draft.dart | 3 +- .../presentation/task_details_pane_test.dart | 29 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/src/features/tasks/presentation/task_details_draft.dart b/lib/src/features/tasks/presentation/task_details_draft.dart index 7530db8..72bcf63 100644 --- a/lib/src/features/tasks/presentation/task_details_draft.dart +++ b/lib/src/features/tasks/presentation/task_details_draft.dart @@ -310,8 +310,7 @@ String? _timePart(String? value) { } String? _scheduleTimePart(String? value) { - final time = _timePart(value); - return time == '00:00' ? null : time; + return _timePart(value); } Map _graphDateTime( diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 3c64577..9b9110e 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -793,8 +793,8 @@ void main() { 'Microsoft all-day scheduled tasks can be switched to time slot', (tester) async { final repository = _FakeTasksRepository( - microsoftDueDateTime: '2026-06-06T00:00:00', - microsoftStartDateTime: '2026-06-04T00:00:00', + microsoftDueDateTime: '2026-06-06', + microsoftStartDateTime: '2026-06-04', ); await _pumpDetails( tester, @@ -827,6 +827,31 @@ void main() { }, ); + 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 { From 8a6a016649bd641a3753ad0891f3291cecc0f51b Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:04:18 -0700 Subject: [PATCH 05/53] Add desktop_multi_window and window_manager dependencies to pubspec --- pubspec.lock | 10 +++++++++- pubspec.yaml | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index e28d1db..a6917cc 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: @@ -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..c6c0cae 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 @@ -34,6 +35,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 From d61e22d19b89a929d1fdaebfbdce215514ae7c03 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:04:32 -0700 Subject: [PATCH 06/53] Register desktop_multi_window plugin and update plugin registration logic --- linux/flutter/generated_plugin_registrant.cc | 4 ++++ linux/flutter/generated_plugins.cmake | 1 + linux/runner/my_application.cc | 3 +++ 3 files changed, 8 insertions(+) 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/runner/my_application.cc b/linux/runner/my_application.cc index 6ced418..f0a8aef 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -11,6 +11,7 @@ #endif #include "flutter/generated_plugin_registrant.h" +#include "desktop_multi_window/desktop_multi_window_plugin.h" constexpr char kApplicationDisplayName[] = "BusyMax"; constexpr char kNativeDateTimePickerChannel[] = @@ -2176,6 +2177,8 @@ 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) { fl_register_plugins(registry); }); register_native_date_time_picker(self, view, window); register_window_channel(self, view); register_header_bar_channel(self, view); From ac8e8e24557f70d569f908057beed1de71809c6e Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:04:51 -0700 Subject: [PATCH 07/53] Implement compact agenda window service and enhance main window command bridge --- lib/src/platform/busymax_tray_service.dart | 11 +- lib/src/platform/busymax_window_args.dart | 53 ++++++++ .../compact_agenda_window_service.dart | 84 ++++++++++++ .../platform/main_window_command_bridge.dart | 128 ++++++++++++++++++ .../platform/main_window_command_client.dart | 52 +++++++ 5 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 lib/src/platform/busymax_window_args.dart create mode 100644 lib/src/platform/compact_agenda_window_service.dart create mode 100644 lib/src/platform/main_window_command_bridge.dart create mode 100644 lib/src/platform/main_window_command_client.dart diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index 49d7428..7988969 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(); } diff --git a/lib/src/platform/busymax_window_args.dart b/lib/src/platform/busymax_window_args.dart new file mode 100644 index 0000000..94fab5c --- /dev/null +++ b/lib/src/platform/busymax_window_args.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +enum BusyMaxWindowKind { main, compactAgenda } + +class BusyMaxWindowArgs { + const BusyMaxWindowArgs({required this.kind, required this.version}); + + final BusyMaxWindowKind kind; + final int version; + + static const currentVersion = 1; + + static const main = BusyMaxWindowArgs( + kind: BusyMaxWindowKind.main, + version: currentVersion, + ); + + static const compactAgenda = BusyMaxWindowArgs( + kind: BusyMaxWindowKind.compactAgenda, + version: currentVersion, + ); + + String encode() { + return jsonEncode({ + 'app': 'BusyMax', + 'version': version, + 'kind': kind.name, + }); + } + + 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) { + 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..3485fa2 --- /dev/null +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -0,0 +1,84 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; + +import 'busymax_window_args.dart'; + +class CompactAgendaWindowService { + const CompactAgendaWindowService(); + + Future toggle() async { + final controller = await _findCompactAgendaWindow(); + if (controller == null) { + await _createCompactAgendaWindow(); + return; + } + await _invokeOrShow(controller, 'busymax.compactAgenda.toggle'); + } + + Future show() async { + final controller = await _findCompactAgendaWindow(); + if (controller == null) { + await _createCompactAgendaWindow(); + return; + } + await _invokeOrShow(controller, 'busymax.compactAgenda.show'); + } + + 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() async { + final controller = await WindowController.create( + WindowConfiguration( + arguments: BusyMaxWindowArgs.compactAgenda.encode(), + hiddenAtLaunch: true, + ), + ); + await _invokeOrShow(controller, 'busymax.compactAgenda.show'); + } + + Future _invokeOrShow( + WindowController controller, + String method, + ) async { + try { + await controller.invokeMethod(method); + } on Object { + await controller.show(); + } + } + + Future _invokeOrIgnore( + WindowController controller, + String method, + ) async { + try { + await controller.invokeMethod(method); + } on Object { + // Window may already be closing; nothing useful to do. + } + } +} 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..d6481fa --- /dev/null +++ b/lib/src/platform/main_window_command_bridge.dart @@ -0,0 +1,128 @@ +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); + } + + 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(); + return true; + } + + @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..ffef748 --- /dev/null +++ b/lib/src/platform/main_window_command_client.dart @@ -0,0 +1,52 @@ +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}, + ); + } +} From 3d7bb69ff996d165c2cb19cfa778752979b83d21 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:05:02 -0700 Subject: [PATCH 08/53] Integrate compact agenda window service and update tray agenda handling --- lib/src/app/app_bootstrap.dart | 6 ++++++ lib/src/app/app_router.dart | 5 ----- lib/src/app/busymax_app.dart | 15 ++++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index d337551..6869ce7 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -31,6 +31,7 @@ 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'; @@ -123,6 +124,11 @@ final linuxHeaderBarServiceProvider = Provider((ref) { return service; }); +final compactAgendaWindowServiceProvider = + Provider((ref) { + return const CompactAgendaWindowService(); + }); + final authRepositoryProvider = Provider((ref) { return AuthRepository( oAuth: ref.watch(applicationOAuthServiceProvider), 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/busymax_app.dart b/lib/src/app/busymax_app.dart index 1ad7db0..e8de3f7 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, @@ -178,10 +181,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 +194,6 @@ class _BusyMaxAppState extends ConsumerState { unawaited(tray.stop()); } } - - Future _openTrayAgenda(WidgetRef ref) async { - ref.read(appRouterProvider).go('/tray-agenda'); - } } class _BusyMaxWindowCornerClip extends StatelessWidget { From 25703d111b29e9d78f10a740e0ee43afb36fcdaf Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:07:10 -0700 Subject: [PATCH 09/53] Add compact agenda formatting functions for date and time display --- .../compact_agenda_formatting.dart | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 lib/src/features/schedule/presentation/compact_agenda_formatting.dart 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..b401882 --- /dev/null +++ b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart @@ -0,0 +1,93 @@ +import 'package:flutter/widgets.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), + ); +} From 361f7e9c2d466291759efc8ae26e6cc8edc2698c Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:07:27 -0700 Subject: [PATCH 10/53] Add compact agenda controller and data provider for task management --- .../compact_agenda_controller.dart | 48 +++++++ .../application/compact_agenda_data.dart | 124 ++++++++++++++++++ .../application/compact_agenda_sections.dart | 74 +++++++++++ 3 files changed, 246 insertions(+) create mode 100644 lib/src/features/schedule/application/compact_agenda_controller.dart create mode 100644 lib/src/features/schedule/application/compact_agenda_data.dart create mode 100644 lib/src/features/schedule/application/compact_agenda_sections.dart 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..aca2537 --- /dev/null +++ b/lib/src/features/schedule/application/compact_agenda_controller.dart @@ -0,0 +1,48 @@ +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 '../../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 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)); + + try { + await const MainWindowCommandClient().requestTaskSync(item.accountId); + } on Object { + // The pending operation remains queued and will sync when the main engine + // is available. + } + + _ref.invalidate(compactAgendaDataProvider); + } +} + +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..ca655e9 --- /dev/null +++ b/lib/src/features/schedule/application/compact_agenda_data.dart @@ -0,0 +1,124 @@ +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'; + +final compactAgendaDataProvider = FutureProvider.autoDispose( + (ref) async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final end = today.add(const Duration(days: 7)); + final queryStart = today.subtract(const Duration(days: 30)); + final range = ScheduleRange(start: today, end: end); + + CompactAgendaData empty({ + required bool hasSignedInAccounts, + required bool hasSources, + }) { + return CompactAgendaData( + today: today, + range: range, + items: const [], + 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 rawItems = await ref + .read(scheduleRepositoryProvider) + .listItems( + range: ScheduleRange(start: queryStart, end: end), + filters: ScheduleFilters( + accountIds: accountIds, + sourceIds: visibility.visibleCalendarSourceIds, + taskListIds: visibility.visibleTaskListIds, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: true, + includeTasks: true, + showCompletedTasks: false, + showNoDateTasks: false, + ), + ); + + final items = rawItems.where((item) { + final start = item.start; + if (start == null) { + return false; + } + 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, + hasSignedInAccounts: true, + hasSources: true, + generatedAt: now, + ); + }, +); + +class CompactAgendaData { + const CompactAgendaData({ + required this.today, + required this.range, + required this.items, + required this.hasSignedInAccounts, + required this.hasSources, + required this.generatedAt, + }); + + final DateTime today; + final ScheduleRange range; + final List items; + final bool hasSignedInAccounts; + final bool hasSources; + final DateTime generatedAt; +} 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..78350da --- /dev/null +++ b/lib/src/features/schedule/application/compact_agenda_sections.dart @@ -0,0 +1,74 @@ +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_projection.dart'; +import '../../../schedule/schedule_sorting.dart'; + +enum CompactAgendaSectionKind { overdue, day } + +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, +}) { + 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(8).toList(), + hasMore: overdueTasks.length > 8, + ), + ); + } + + final grouped = >{}; + final end = today.add(const Duration(days: 7)); + for (final item in items) { + if (item is TaskScheduleItem && item.completed) { + continue; + } + final start = item.start; + if (start == null) { + continue; + } + final day = ScheduleProjection.day(start); + if (day.isBefore(today) || !day.isBefore(end)) { + continue; + } + grouped.putIfAbsent(day, () => []).add(item); + } + + final days = grouped.keys.toList()..sort(); + for (final day in days) { + final dayItems = grouped[day]!..sort(compareScheduleItems); + sections.add( + CompactAgendaSection( + kind: CompactAgendaSectionKind.day, + day: day, + items: dayItems, + ), + ); + } + + return sections; +} From 81c0633aa13c12911e187daf6466d28d4e135a2d Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:13:31 -0700 Subject: [PATCH 11/53] Add unit tests for BusyMaxWindowArgs parsing logic --- test/platform/busymax_tray_service_test.dart | 21 +++++++++++ test/platform/busymax_window_args_test.dart | 37 ++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 test/platform/busymax_window_args_test.dart diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index dff6b64..5a88822 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,25 @@ void main() { test('application id uses Busystack reverse DNS id', () { expect(busyMaxApplicationId, 'io.busystack.busymax'); }); + + 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..3c63181 --- /dev/null +++ b/test/platform/busymax_window_args_test.dart @@ -0,0 +1,37 @@ +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('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); + }); +} From 3759ba48b2bfe55bd8a44973a1adb61f68810ded Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:13:45 -0700 Subject: [PATCH 12/53] Add compact agenda localization strings for multiple languages --- lib/l10n/app_de.arb | 14 ++++ lib/l10n/app_en.arb | 15 ++++ lib/l10n/app_es.arb | 14 ++++ lib/l10n/app_fr.arb | 14 ++++ lib/l10n/generated/app_localizations.dart | 84 ++++++++++++++++++++ lib/l10n/generated/app_localizations_de.dart | 45 +++++++++++ lib/l10n/generated/app_localizations_en.dart | 44 ++++++++++ lib/l10n/generated/app_localizations_es.dart | 44 ++++++++++ lib/l10n/generated/app_localizations_fr.dart | 45 +++++++++++ 9 files changed, 319 insertions(+) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e954aed..2837593 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -48,6 +48,20 @@ "trayAgendaOpenBusyMax": "App öffnen", "trayAgendaRefresh": "Aktualisieren", "trayAgendaError": "Agenda nicht verfügbar", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "Nächste 7 Tage", + "compactAgendaOverdue": "Überfällig", + "compactAgendaClear": "Frei für die nächsten 7 Tage", + "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 in BusyMax", "viewDay": "Tag", "viewWeek": "Woche", "viewMonth": "Monat", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d7900bf..e961ee5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -49,6 +49,21 @@ "trayAgendaOpenBusyMax": "Open app", "trayAgendaRefresh": "Refresh", "trayAgendaError": "Agenda unavailable", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "Next 7 days", + "compactAgendaOverdue": "Overdue", + "compactAgendaClear": "Clear for the next 7 days", + "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": "More overdue tasks in BusyMax", "viewDay": "Day", "viewWeek": "Week", "viewMonth": "Month", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b568db2..67ed99b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -48,6 +48,20 @@ "trayAgendaOpenBusyMax": "Abrir app", "trayAgendaRefresh": "Actualizar", "trayAgendaError": "Agenda no disponible", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "Próximos 7 días", + "compactAgendaOverdue": "Vencidas", + "compactAgendaClear": "Libre durante los próximos 7 días", + "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": "Más tareas vencidas en BusyMax", "viewDay": "Día", "viewWeek": "Semana", "viewMonth": "Mes", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e4d265d..7ea266f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -48,6 +48,20 @@ "trayAgendaOpenBusyMax": "Ouvrir l’app", "trayAgendaRefresh": "Actualiser", "trayAgendaError": "Agenda indisponible", + "compactAgendaTitle": "Agenda", + "compactAgendaSubtitle": "7 prochains jours", + "compactAgendaOverdue": "En retard", + "compactAgendaClear": "Libre pour les 7 prochains jours", + "compactAgendaOpenBusyMax": "Ouvrir BusyMax", + "compactAgendaHide": "Masquer", + "compactAgendaNewTask": "Nouvelle tâche", + "compactAgendaRetry": "Réessayer", + "compactAgendaRefresh": "Actualiser", + "compactAgendaAllDay": "Toute la journée", + "compactAgendaDueToday": "Due aujourd’hui", + "compactAgendaDueTomorrow": "Due demain", + "compactAgendaDueOn": "Due {date}", + "compactAgendaMoreOverdue": "Plus de tâches en retard dans BusyMax", "viewDay": "Jour", "viewWeek": "Semaine", "viewMonth": "Mois", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 4b80faf..468f604 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -390,6 +390,90 @@ 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: + /// **'Next 7 days'** + 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 the next 7 days'** + 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: + /// **'More overdue tasks in BusyMax'** + String get compactAgendaMoreOverdue; + /// No description provided for @viewDay. /// /// 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..f76658b 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -162,6 +162,51 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trayAgendaError => 'Agenda nicht verfügbar'; + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => 'Nächste 7 Tage'; + + @override + String get compactAgendaOverdue => 'Überfällig'; + + @override + String get compactAgendaClear => 'Frei für die nächsten 7 Tage'; + + @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 in BusyMax'; + @override String get viewDay => 'Tag'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a807995..b5b0e9a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -160,6 +160,50 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trayAgendaError => 'Agenda unavailable'; + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => 'Next 7 days'; + + @override + String get compactAgendaOverdue => 'Overdue'; + + @override + String get compactAgendaClear => 'Clear for the next 7 days'; + + @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 => 'More overdue tasks in BusyMax'; + @override String get viewDay => 'Day'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 7412ba0..3e6f079 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -164,6 +164,50 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trayAgendaError => 'Agenda no disponible'; + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => 'Próximos 7 días'; + + @override + String get compactAgendaOverdue => 'Vencidas'; + + @override + String get compactAgendaClear => 'Libre durante los próximos 7 días'; + + @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 => 'Más tareas vencidas en BusyMax'; + @override String get viewDay => 'Día'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index aa93ade..3140ca6 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -163,6 +163,51 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trayAgendaError => 'Agenda indisponible'; + @override + String get compactAgendaTitle => 'Agenda'; + + @override + String get compactAgendaSubtitle => '7 prochains jours'; + + @override + String get compactAgendaOverdue => 'En retard'; + + @override + String get compactAgendaClear => 'Libre pour les 7 prochains jours'; + + @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 => 'Due aujourd’hui'; + + @override + String get compactAgendaDueTomorrow => 'Due demain'; + + @override + String compactAgendaDueOn(String date) { + return 'Due $date'; + } + + @override + String get compactAgendaMoreOverdue => + 'Plus de tâches en retard dans BusyMax'; + @override String get viewDay => 'Jour'; From 7778e824cb3c47bca768576b20cfac02fd44dc7d Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:13:55 -0700 Subject: [PATCH 13/53] Add compact agenda application and panel implementation --- .../presentation/compact_agenda_app.dart | 155 ++++ .../compact_agenda_formatting.dart | 4 +- .../presentation/compact_agenda_panel.dart | 779 ++++++++++++++++++ .../presentation/tray_agenda_screen.dart | 274 ------ 4 files changed, 936 insertions(+), 276 deletions(-) create mode 100644 lib/src/features/schedule/presentation/compact_agenda_app.dart create mode 100644 lib/src/features/schedule/presentation/compact_agenda_panel.dart delete mode 100644 lib/src/features/schedule/presentation/tray_agenda_screen.dart 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..875c8da --- /dev/null +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:desktop_multi_window/desktop_multi_window.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/system_accent.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../../../platform/gtk_font_service.dart'; +import '../application/compact_agenda_data.dart'; +import 'compact_agenda_panel.dart'; + +class BusyMaxCompactAgendaApp extends ConsumerStatefulWidget { + const BusyMaxCompactAgendaApp({required this.windowController, super.key}); + + final WindowController windowController; + + @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)); + } + + @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(); + return true; + case 'busymax.compactAgenda.hide': + await windowManager.hide(); + return true; + case 'busymax.compactAgenda.toggle': + final visible = await windowManager.isVisible(); + final focused = await _isFocused(); + if (visible && focused) { + await windowManager.hide(); + } else { + await _show(); + } + return true; + case 'busymax.compactAgenda.refresh': + ref.invalidate(compactAgendaDataProvider); + return true; + case 'busymax.compactAgenda.destroy': + await windowManager.setPreventClose(false); + await windowManager.destroy(); + return true; + } + + throw MissingPluginException('Not implemented: ${call.method}'); + } + + Future _show() async { + await windowManager.setAlignment(Alignment.topRight); + await windowManager.show(); + await windowManager.focus(); + ref.invalidate(compactAgendaDataProvider); + } + + Future _isFocused() async { + try { + return await windowManager.isFocused(); + } on Object { + return false; + } + } + + @override + void onWindowClose() { + unawaited(windowManager.hide()); + } + + @override + void onWindowBlur() { + unawaited(_hideAfterBlurDelay()); + } + + Future _hideAfterBlurDelay() async { + await Future.delayed(const Duration(milliseconds: 180)); + if (!await _isFocused()) { + await windowManager.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: CompactAgendaPanel(), + ), + ); + }, + ); + } +} diff --git a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart index b401882..6e7adb8 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_formatting.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_formatting.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../l10n/l10n.dart'; @@ -24,7 +24,7 @@ String compactAgendaDayLabel( String compactAgendaTodaySubtitle(BuildContext context, DateTime today) { final locale = Localizations.localeOf(context).toString(); - return '${context.l10n.today} · ${DateFormat.MMMd(locale).format(today)}'; + return '${context.l10n.today} - ${DateFormat.MMMd(locale).format(today)}'; } String compactAgendaItemMeta( 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..3e0d360 --- /dev/null +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -0,0 +1,779 @@ +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/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 '../application/compact_agenda_controller.dart'; +import '../application/compact_agenda_data.dart'; +import '../application/compact_agenda_sections.dart'; +import 'compact_agenda_formatting.dart'; + +typedef CompactAgendaTaskCompletionCallback = + Future Function(TaskScheduleItem item, bool completed); + +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 = {}; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + final data = widget.data ?? ref.watch(compactAgendaDataProvider); + 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: Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.sm), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.card, + borderRadius: BorderRadius.circular(BusyMaxRadius.window), + border: Border.all(color: colors.border), + boxShadow: BusyMaxShadow.floatingShadows( + BusyMaxShadow.floatingColor(context), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(BusyMaxRadius.window), + child: Column( + children: [ + _CompactAgendaHeader( + data: data.valueOrNull, + onRefresh: _refresh, + onOpenBusyMax: _openBusyMax, + onHide: _hide, + ), + Expanded(child: _body(data)), + _CompactAgendaBottomBar( + onNewTask: _newTask, + onOpenBusyMax: _openBusyMax, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + 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) { + return _CompactAgendaMessageState( + icon: Icons.login, + title: context.l10n.trayAgendaSignInRequired, + primaryLabel: context.l10n.compactAgendaOpenBusyMax, + onPrimary: _openBusyMax, + ); + } + if (!agenda.hasSources) { + return _CompactAgendaMessageState( + icon: Icons.event_busy_outlined, + title: context.l10n.trayAgendaNoSources, + primaryLabel: context.l10n.compactAgendaOpenBusyMax, + onPrimary: _openBusyMax, + ); + } + if (agenda.items.isEmpty) { + return _CompactAgendaMessageState( + icon: Icons.event_available, + title: context.l10n.compactAgendaClear, + message: context.l10n.noEventsOrTasks, + ); + } + return _sections(agenda); + }, + ); + } + + Widget _sections(CompactAgendaData data) { + final sections = buildCompactAgendaSections( + today: data.today, + items: data.items, + ); + 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, + onOpenBusyMax: _openBusyMax, + ); + }, + ); + } + + 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; + } + await const MainWindowCommandClient().newTask(); + await windowManager.hide(); + } + + Future _refresh() async { + final callback = widget.onRefresh; + if (callback != null) { + await callback(); + return; + } + ref.invalidate(compactAgendaDataProvider); + } + + Future _hide() async { + final callback = widget.onHide; + if (callback != null) { + await callback(); + return; + } + await windowManager.hide(); + } + + Future _openItem(ScheduleItem item) async { + final callback = widget.onOpenItem; + if (callback != null) { + await callback(item); + return; + } + await const MainWindowCommandClient().openScheduleItem(item); + await windowManager.hide(); + } + + 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); + } + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(redactForLog(error))), + ); + } + } finally { + if (mounted) { + setState(() => _mutatingTaskKeys.remove(key)); + } + } + } +} + +class _CompactAgendaHeader extends StatelessWidget { + const _CompactAgendaHeader({ + required this.data, + required this.onRefresh, + required this.onOpenBusyMax, + required this.onHide, + }); + + final CompactAgendaData? data; + final Future Function() onRefresh; + final Future Function() onOpenBusyMax; + 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, + padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), + decoration: BoxDecoration( + color: colors.headerbarFlat, + border: Border(bottom: BorderSide(color: colors.subtleBorder)), + ), + 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()), + ), + _CompactHeaderButton( + tooltip: context.l10n.compactAgendaOpenBusyMax, + icon: Icons.open_in_full, + onPressed: () => unawaited(onOpenBusyMax()), + ), + _CompactHeaderButton( + tooltip: context.l10n.compactAgendaHide, + icon: Icons.close, + onPressed: () => 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 _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 Center( + child: Padding( + padding: const EdgeInsets.all(BusyMaxSpacing.xl), + 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.onOpenBusyMax, + }); + + final CompactAgendaSection section; + final DateTime today; + final Set mutatingTaskKeys; + final Future Function(ScheduleItem item) onOpenItem; + final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; + final Future Function() onOpenBusyMax; + + @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, + ), + }; + return Padding( + padding: const EdgeInsets.only(bottom: BusyMaxSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.xs, + 0, + BusyMaxSpacing.xs, + BusyMaxSpacing.xs, + ), + child: Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: BusyMaxSurfaceColors.of(context).mutedForeground, + fontWeight: FontWeight.w700, + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: BusyMaxSurfaceColors.of(context).view, + borderRadius: BorderRadius.circular(BusyMaxRadius.sm), + border: Border.all( + color: BusyMaxSurfaceColors.of(context).subtleBorder, + ), + ), + child: Column( + children: [ + for (var index = 0; index < section.items.length; index += 1) + _CompactAgendaRow( + item: section.items[index], + today: today, + mutating: + section.items[index] is TaskScheduleItem && + mutatingTaskKeys.contains( + compactAgendaTaskMutationKey( + section.items[index] as TaskScheduleItem, + ), + ), + showDivider: index < section.items.length - 1 || + section.hasMore, + onOpenItem: onOpenItem, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + if (section.hasMore) + _MoreOverdueRow(onOpenBusyMax: onOpenBusyMax), + ], + ), + ), + ], + ), + ); + } +} + +class _CompactAgendaRow extends StatelessWidget { + const _CompactAgendaRow({ + required this.item, + required this.today, + required this.mutating, + required this.showDivider, + required this.onOpenItem, + required this.onTaskCompletionChanged, + }); + + final ScheduleItem item; + final DateTime today; + final bool mutating; + final bool showDivider; + final Future Function(ScheduleItem item) onOpenItem; + final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; + + @override + Widget build(BuildContext context) { + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final color = ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); + final source = ScheduleProjection.sourceLabelForScheduleItem(item); + final meta = compactAgendaItemMeta(context, item, today: today); + final event = item is CalendarScheduleItem + ? item as CalendarScheduleItem + : null; + return AnimatedOpacity( + opacity: mutating ? 0.48 : 1, + duration: const Duration(milliseconds: 120), + child: InkWell( + onTap: mutating ? null : () => unawaited(onOpenItem(item)), + child: Container( + constraints: const BoxConstraints(minHeight: 62), + decoration: BoxDecoration( + border: showDivider + ? Border( + bottom: BorderSide( + color: BusyMaxSurfaceColors.of(context).subtleBorder, + ), + ) + : null, + ), + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (task != null) ...[ + YaruCheckbox( + value: task.completed, + onChanged: mutating + ? null + : (value) => unawaited( + onTaskCompletionChanged(task, value ?? false), + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + ] else ...[ + Container( + width: 4, + height: 42, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: BusyMaxSpacing.md), + ], + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: BusyMaxSpacing.xxs), + Text( + [if (meta.isNotEmpty) meta, source].join(' - '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: BusyMaxSurfaceColors.of(context).mutedForeground, + ), + ), + if (event?.location?.trim().isNotEmpty == true) ...[ + const SizedBox(height: BusyMaxSpacing.xxs), + Text( + event!.location!.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + BusyMaxSurfaceColors.of(context).mutedForeground, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MoreOverdueRow extends StatelessWidget { + const _MoreOverdueRow({required this.onOpenBusyMax}); + + final Future Function() onOpenBusyMax; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => unawaited(onOpenBusyMax()), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: BusyMaxSpacing.md, + vertical: BusyMaxSpacing.sm, + ), + child: Row( + children: [ + const Icon(Icons.open_in_full, size: BusyMaxSizes.iconSm), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: Text( + context.l10n.compactAgendaMoreOverdue, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _CompactAgendaBottomBar extends StatelessWidget { + const _CompactAgendaBottomBar({ + required this.onNewTask, + required this.onOpenBusyMax, + }); + + final Future Function() onNewTask; + final Future Function() onOpenBusyMax; + + @override + Widget build(BuildContext context) { + final colors = BusyMaxSurfaceColors.of(context); + return Container( + padding: const EdgeInsets.all(BusyMaxSpacing.md), + decoration: BoxDecoration( + color: colors.headerbarFlat, + border: Border(top: BorderSide(color: colors.subtleBorder)), + ), + child: Row( + children: [ + Expanded( + child: BusyMaxPushButton.filled( + onPressed: () => unawaited(onNewTask()), + child: Text(context.l10n.compactAgendaNewTask), + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), + Expanded( + child: BusyMaxPushButton.outlined( + onPressed: () => unawaited(onOpenBusyMax()), + child: Text(context.l10n.compactAgendaOpenBusyMax), + ), + ), + ], + ), + ); + } +} + +class _HideIntent extends Intent { + const _HideIntent(); +} + +class _RefreshIntent extends Intent { + const _RefreshIntent(); +} 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; -} From 12ea63878e73f1cac0ada6cc002d26ab93d29627 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:14:03 -0700 Subject: [PATCH 14/53] Add support for compact agenda window in main application --- lib/main.dart | 61 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index da7171b..3de9d2e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,23 +1,68 @@ +import 'dart:async'; + +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), + ), + ); + } +} + +Future configureCompactAgendaNativeWindow() async { + await windowManager.ensureInitialized(); + + const size = Size(420, 680); + const options = WindowOptions( + size: size, + minimumSize: Size(360, 520), + maximumSize: Size(480, 840), + center: false, + backgroundColor: Colors.transparent, + skipTaskbar: true, + title: 'BusyMax Agenda', + titleBarStyle: TitleBarStyle.hidden, + windowButtonVisibility: false, ); + + await windowManager.waitUntilReadyToShow(options, () { + unawaited(() async { + await windowManager.setPreventClose(true); + await windowManager.setResizable(false); + await windowManager.setAlignment(Alignment.topRight); + await windowManager.show(); + await windowManager.focus(); + }()); + }); } From 1e0c138a86b145f0b79e87514dba57490ae05e02 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:16:14 -0700 Subject: [PATCH 15/53] Add unit tests for compact agenda functionality and panel behavior --- test/app/native_ui_audit_test.dart | 27 +++ .../compact_agenda_sections_test.dart | 123 ++++++++++++ .../compact_agenda_panel_test.dart | 181 ++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 test/features/schedule/application/compact_agenda_sections_test.dart create mode 100644 test/features/schedule/presentation/compact_agenda_panel_test.dart diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index d1cf95b..ace8d5d 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -130,6 +130,33 @@ 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 tray = File( + 'lib/src/platform/busymax_tray_service.dart', + ).readAsStringSync(); + final router = File('lib/src/app/app_router.dart').readAsStringSync(); + final compactApp = File( + 'lib/src/features/schedule/presentation/compact_agenda_app.dart', + ).readAsStringSync(); + + expect(pubspec, contains('desktop_multi_window:')); + expect(pubspec, contains('window_manager:')); + expect( + runner, + contains('desktop_multi_window_plugin_set_window_created_callback'), + ); + 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, isNot(contains('syncSchedulerProvider'))); + expect(compactApp, isNot(contains('notificationSchedulerProvider'))); + expect(compactApp, isNot(contains('dueTodayNotificationProvider'))); + }); + test('native headerbar keeps sidebar top branded and aligned', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); 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..9ed2ca6 --- /dev/null +++ b/test/features/schedule/application/compact_agenda_sections_test.dart @@ -0,0 +1,123 @@ +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('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); + }); +} + +TaskScheduleItem _task( + String title, { + required 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..2a987ea --- /dev/null +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -0,0 +1,181 @@ +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('empty state shows positive empty message', (tester) async { + await tester.pumpWidget(_testPanel(data: _data(today))); + + expect(find.text('Clear for the next 7 days'), findsOneWidget); + expect(find.text('No events or tasks'), 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('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('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); + }); +} + +Widget _testPanel({ + required AsyncValue data, + Future Function(ScheduleItem item)? onOpenItem, + CompactAgendaTaskCompletionCallback? onTaskCompletionChanged, +}) { + return ProviderScope( + child: localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 420, + height: 680, + child: CompactAgendaPanel( + data: data, + onOpenBusyMax: () async {}, + onNewTask: () async {}, + onRefresh: () async {}, + onHide: () async {}, + onOpenItem: onOpenItem, + onTaskCompletionChanged: onTaskCompletionChanged, + ), + ), + ), + ), + ); +} + +AsyncValue _data( + DateTime today, { + List items = const [], + bool hasSignedInAccounts = true, + bool hasSources = true, +}) { + return AsyncData( + CompactAgendaData( + today: today, + range: ScheduleRange( + start: today, + end: today.add(const Duration(days: 7)), + ), + items: items, + hasSignedInAccounts: hasSignedInAccounts, + hasSources: hasSources, + generatedAt: today, + ), + ); +} + +TaskScheduleItem _task(String title, {required 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', + ); +} From 1919b0d431af741407500ad2f705aa54068ab7e2 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:19:04 -0700 Subject: [PATCH 16/53] Refactor method calls for improved readability in compact agenda services --- .../compact_agenda_window_service.dart | 5 +-- .../platform/main_window_command_bridge.dart | 38 ++++++++++--------- .../platform/main_window_command_client.dart | 18 ++++----- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart index 3485fa2..89aadc6 100644 --- a/lib/src/platform/compact_agenda_window_service.dart +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -60,10 +60,7 @@ class CompactAgendaWindowService { await _invokeOrShow(controller, 'busymax.compactAgenda.show'); } - Future _invokeOrShow( - WindowController controller, - String method, - ) async { + Future _invokeOrShow(WindowController controller, String method) async { try { await controller.invokeMethod(method); } on Object { diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart index d6481fa..a346f37 100644 --- a/lib/src/platform/main_window_command_bridge.dart +++ b/lib/src/platform/main_window_command_bridge.dart @@ -26,9 +26,7 @@ class _MainWindowCommandBridgeState @override void initState() { super.initState(); - unawaited( - busyMaxMainWindowChannel.setMethodCallHandler(_handleMethodCall), - ); + unawaited(busyMaxMainWindowChannel.setMethodCallHandler(_handleMethodCall)); } @override @@ -85,26 +83,28 @@ class _MainWindowCommandBridgeState } await ref.read(linuxWindowServiceProvider).showWindow(); - ref.read(scheduleWorkspaceCommandProvider.notifier).state = - ScheduleWorkspaceCommand( - commandKind, - ++_sequence, - date: date, - accountId: accountId, - sourceId: sourceId, - itemId: itemId, - ); + 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(scheduleWorkspaceCommandProvider.notifier) + .state = ScheduleWorkspaceCommand( + ScheduleWorkspaceCommandKind.newTask, + ++_sequence, + ); ref.read(appRouterProvider).go('/schedule'); return true; } @@ -117,7 +117,9 @@ class _MainWindowCommandBridgeState if (accountId == null || accountId.isEmpty) { return false; } - ref.read(pendingMutationSyncRequesterForAccountProvider(accountId)).request(); + ref + .read(pendingMutationSyncRequesterForAccountProvider(accountId)) + .request(); return true; } diff --git a/lib/src/platform/main_window_command_client.dart b/lib/src/platform/main_window_command_client.dart index ffef748..01f0436 100644 --- a/lib/src/platform/main_window_command_client.dart +++ b/lib/src/platform/main_window_command_client.dart @@ -21,16 +21,14 @@ class MainWindowCommandClient { 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(), - }, - ); + 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 { From eba0d4f5f29839107237349e1169d72005374e1b Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:19:13 -0700 Subject: [PATCH 17/53] Refactor code for improved readability and consistency in compact agenda components --- .../application/compact_agenda_data.dart | 3 +-- .../application/compact_agenda_sections.dart | 3 +-- .../presentation/compact_agenda_app.dart | 2 +- .../presentation/compact_agenda_panel.dart | 23 +++++++++---------- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart index ca655e9..9947f0a 100644 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ b/lib/src/features/schedule/application/compact_agenda_data.dart @@ -91,8 +91,7 @@ final compactAgendaDataProvider = FutureProvider.autoDispose( return !item.completed && start.isBefore(end); } return false; - }).toList() - ..sort(compareScheduleItems); + }).toList()..sort(compareScheduleItems); return CompactAgendaData( today: today, diff --git a/lib/src/features/schedule/application/compact_agenda_sections.dart b/lib/src/features/schedule/application/compact_agenda_sections.dart index 78350da..3dbef5a 100644 --- a/lib/src/features/schedule/application/compact_agenda_sections.dart +++ b/lib/src/features/schedule/application/compact_agenda_sections.dart @@ -27,8 +27,7 @@ List buildCompactAgendaSections({ return start != null && !item.completed && ScheduleProjection.day(start).isBefore(today); - }).toList() - ..sort(compareScheduleItems); + }).toList()..sort(compareScheduleItems); final sections = []; if (overdueTasks.isNotEmpty) { diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index 875c8da..eb8de16 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -1,6 +1,7 @@ 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'; @@ -11,7 +12,6 @@ import 'package:window_manager/window_manager.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/app_theme.dart'; import '../../../app/system_accent.dart'; -import '../../../l10n/generated/app_localizations.dart'; import '../../../platform/gtk_font_service.dart'; import '../application/compact_agenda_data.dart'; import 'compact_agenda_panel.dart'; diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 3e0d360..6c671e3 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -51,7 +51,8 @@ class _CompactAgendaPanelState extends ConsumerState { @override Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); - final data = widget.data ?? ref.watch(compactAgendaDataProvider); + final AsyncValue data = + widget.data ?? ref.watch(compactAgendaDataProvider); return Shortcuts( shortcuts: const { SingleActivator(LogicalKeyboardKey.escape): _HideIntent(), @@ -227,10 +228,7 @@ class _CompactAgendaPanelState extends ConsumerState { await windowManager.hide(); } - Future _setTaskCompleted( - TaskScheduleItem item, - bool completed, - ) async { + Future _setTaskCompleted(TaskScheduleItem item, bool completed) async { final key = compactAgendaTaskMutationKey(item); if (_mutatingTaskKeys.contains(key)) { return; @@ -247,9 +245,9 @@ class _CompactAgendaPanelState extends ConsumerState { } } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(redactForLog(error))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); } } finally { if (mounted) { @@ -561,8 +559,8 @@ class _CompactAgendaSectionView extends StatelessWidget { section.items[index] as TaskScheduleItem, ), ), - showDivider: index < section.items.length - 1 || - section.hasMore, + showDivider: + index < section.items.length - 1 || section.hasMore, onOpenItem: onOpenItem, onTaskCompletionChanged: onTaskCompletionChanged, ), @@ -679,8 +677,9 @@ class _CompactAgendaRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: - BusyMaxSurfaceColors.of(context).mutedForeground, + color: BusyMaxSurfaceColors.of( + context, + ).mutedForeground, ), ), ], From af275a84a2e2c63b247a6adc76071b65efba8def Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:30:12 -0700 Subject: [PATCH 18/53] Remove unused GTK font and theme color references from compact agenda implementation --- .../schedule/presentation/compact_agenda_app.dart | 9 --------- test/app/native_ui_audit_test.dart | 3 +++ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index eb8de16..3afa27b 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -12,7 +12,6 @@ import 'package:window_manager/window_manager.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/app_theme.dart'; import '../../../app/system_accent.dart'; -import '../../../platform/gtk_font_service.dart'; import '../application/compact_agenda_data.dart'; import 'compact_agenda_panel.dart'; @@ -113,8 +112,6 @@ class _BusyMaxCompactAgendaAppState final ubuntuAccentColor = ref .watch(ubuntuSystemAccentColorProvider) .valueOrNull; - final gtkFont = ref.watch(gtkFontSettingsProvider).valueOrNull; - final gtkThemeColors = ref.watch(gtkThemeColorsProvider).valueOrNull; return SystemThemeBuilder( builder: (context, systemColor) { @@ -126,17 +123,11 @@ class _BusyMaxCompactAgendaAppState 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 [ diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index ace8d5d..f0b1928 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -152,6 +152,9 @@ void main() { expect(tray, isNot(contains('BusyMaxTrayAgendaEntry'))); expect(router, isNot(contains('/tray-agenda'))); expect(compactApp, isNot(contains('linux_header_bar_service.dart'))); + expect(compactApp, isNot(contains('gtk_font_service.dart'))); + expect(compactApp, isNot(contains('gtkFontSettingsProvider'))); + expect(compactApp, isNot(contains('gtkThemeColorsProvider'))); expect(compactApp, isNot(contains('syncSchedulerProvider'))); expect(compactApp, isNot(contains('notificationSchedulerProvider'))); expect(compactApp, isNot(contains('dueTodayNotificationProvider'))); From 0532d8b0d025feebf092f22215bfc4f29f123506 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:32:27 -0700 Subject: [PATCH 19/53] Update French localization for compact agenda due dates --- lib/l10n/app_fr.arb | 6 +++--- lib/l10n/generated/app_localizations_fr.dart | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7ea266f..1f61d6c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -58,9 +58,9 @@ "compactAgendaRetry": "Réessayer", "compactAgendaRefresh": "Actualiser", "compactAgendaAllDay": "Toute la journée", - "compactAgendaDueToday": "Due aujourd’hui", - "compactAgendaDueTomorrow": "Due demain", - "compactAgendaDueOn": "Due {date}", + "compactAgendaDueToday": "Échéance aujourd’hui", + "compactAgendaDueTomorrow": "Échéance demain", + "compactAgendaDueOn": "Échéance {date}", "compactAgendaMoreOverdue": "Plus de tâches en retard dans BusyMax", "viewDay": "Jour", "viewWeek": "Semaine", diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 3140ca6..28c9c64 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -194,14 +194,14 @@ class AppLocalizationsFr extends AppLocalizations { String get compactAgendaAllDay => 'Toute la journée'; @override - String get compactAgendaDueToday => 'Due aujourd’hui'; + String get compactAgendaDueToday => 'Échéance aujourd’hui'; @override - String get compactAgendaDueTomorrow => 'Due demain'; + String get compactAgendaDueTomorrow => 'Échéance demain'; @override String compactAgendaDueOn(String date) { - return 'Due $date'; + return 'Échéance $date'; } @override From 13126febba6235420cf3dbf7b2f84a66f380b6c3 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 18:59:26 -0700 Subject: [PATCH 20/53] Refactor compact agenda window management and enhance item detail handling --- lib/main.dart | 29 +---------- .../presentation/compact_agenda_app.dart | 27 +++++++--- .../presentation/compact_agenda_panel.dart | 48 ++++++++++++++--- .../compact_agenda_window_service.dart | 28 ++++++---- linux/runner/my_application.cc | 51 ++++++++++++++++++- test/app/native_ui_audit_test.dart | 19 +++++++ .../compact_agenda_panel_test.dart | 15 ++++++ 7 files changed, 166 insertions(+), 51 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 3de9d2e..5193160 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -26,9 +24,7 @@ Future main(List args) async { switch (windowArgs.kind) { case BusyMaxWindowKind.main: - runApp( - ProviderScope(overrides: overrides, child: const BusyMaxApp()), - ); + runApp(ProviderScope(overrides: overrides, child: const BusyMaxApp())); case BusyMaxWindowKind.compactAgenda: await configureCompactAgendaNativeWindow(); runApp( @@ -42,27 +38,4 @@ Future main(List args) async { Future configureCompactAgendaNativeWindow() async { await windowManager.ensureInitialized(); - - const size = Size(420, 680); - const options = WindowOptions( - size: size, - minimumSize: Size(360, 520), - maximumSize: Size(480, 840), - center: false, - backgroundColor: Colors.transparent, - skipTaskbar: true, - title: 'BusyMax Agenda', - titleBarStyle: TitleBarStyle.hidden, - windowButtonVisibility: false, - ); - - await windowManager.waitUntilReadyToShow(options, () { - unawaited(() async { - await windowManager.setPreventClose(true); - await windowManager.setResizable(false); - await windowManager.setAlignment(Alignment.topRight); - await windowManager.show(); - await windowManager.focus(); - }()); - }); } diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index 3afa27b..b6e1812 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -36,6 +36,11 @@ class _BusyMaxCompactAgendaAppState ); windowManager.addListener(this); unawaited(windowManager.setPreventClose(true)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_show()); + } + }); } @override @@ -51,13 +56,13 @@ class _BusyMaxCompactAgendaAppState await _show(); return true; case 'busymax.compactAgenda.hide': - await windowManager.hide(); + await widget.windowController.hide(); return true; case 'busymax.compactAgenda.toggle': final visible = await windowManager.isVisible(); final focused = await _isFocused(); if (visible && focused) { - await windowManager.hide(); + await widget.windowController.hide(); } else { await _show(); } @@ -75,12 +80,20 @@ class _BusyMaxCompactAgendaAppState } Future _show() async { - await windowManager.setAlignment(Alignment.topRight); - await windowManager.show(); - await windowManager.focus(); + await widget.windowController.show(); + unawaited(_focusTopRight()); ref.invalidate(compactAgendaDataProvider); } + Future _focusTopRight() async { + try { + await windowManager.setAlignment(Alignment.topRight); + await windowManager.focus(); + } on Object { + // Positioning is best-effort, especially on Wayland. + } + } + Future _isFocused() async { try { return await windowManager.isFocused(); @@ -91,7 +104,7 @@ class _BusyMaxCompactAgendaAppState @override void onWindowClose() { - unawaited(windowManager.hide()); + unawaited(widget.windowController.hide()); } @override @@ -102,7 +115,7 @@ class _BusyMaxCompactAgendaAppState Future _hideAfterBlurDelay() async { await Future.delayed(const Duration(milliseconds: 180)); if (!await _isFocused()) { - await windowManager.hide(); + await widget.windowController.hide(); } } diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 6c671e3..0031a33 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -17,6 +17,8 @@ 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); @@ -218,14 +220,46 @@ class _CompactAgendaPanelState extends ConsumerState { await windowManager.hide(); } - Future _openItem(ScheduleItem item) async { + Future _openItem(BuildContext anchorContext, ScheduleItem item) async { final callback = widget.onOpenItem; if (callback != null) { await callback(item); return; } - await const MainWindowCommandClient().openScheduleItem(item); - await windowManager.hide(); + final action = await showScheduleItemDetailsPopover( + context: context, + anchorContext: anchorContext, + item: item, + ); + if (!mounted || action == null) { + return; + } + switch (action) { + case ScheduleItemDetailsAction.export: + await _exportItem(item); + case ScheduleItemDetailsAction.edit: + 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 { @@ -504,7 +538,8 @@ class _CompactAgendaSectionView extends StatelessWidget { final CompactAgendaSection section; final DateTime today; final Set mutatingTaskKeys; - final Future Function(ScheduleItem item) onOpenItem; + final Future Function(BuildContext anchorContext, ScheduleItem item) + onOpenItem; final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; final Future Function() onOpenBusyMax; @@ -589,7 +624,8 @@ class _CompactAgendaRow extends StatelessWidget { final DateTime today; final bool mutating; final bool showDivider; - final Future Function(ScheduleItem item) onOpenItem; + final Future Function(BuildContext anchorContext, ScheduleItem item) + onOpenItem; final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; @override @@ -608,7 +644,7 @@ class _CompactAgendaRow extends StatelessWidget { opacity: mutating ? 0.48 : 1, duration: const Duration(milliseconds: 120), child: InkWell( - onTap: mutating ? null : () => unawaited(onOpenItem(item)), + onTap: mutating ? null : () => unawaited(onOpenItem(context, item)), child: Container( constraints: const BoxConstraints(minHeight: 62), decoration: BoxDecoration( diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart index 89aadc6..de6e7a6 100644 --- a/lib/src/platform/compact_agenda_window_service.dart +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -11,7 +11,7 @@ class CompactAgendaWindowService { await _createCompactAgendaWindow(); return; } - await _invokeOrShow(controller, 'busymax.compactAgenda.toggle'); + await _invokeCompactMethod(controller, 'busymax.compactAgenda.toggle'); } Future show() async { @@ -20,7 +20,7 @@ class CompactAgendaWindowService { await _createCompactAgendaWindow(); return; } - await _invokeOrShow(controller, 'busymax.compactAgenda.show'); + await _invokeCompactMethod(controller, 'busymax.compactAgenda.show'); } Future hide() async { @@ -51,20 +51,30 @@ class CompactAgendaWindowService { } Future _createCompactAgendaWindow() async { - final controller = await WindowController.create( + await WindowController.create( WindowConfiguration( arguments: BusyMaxWindowArgs.compactAgenda.encode(), hiddenAtLaunch: true, ), ); - await _invokeOrShow(controller, 'busymax.compactAgenda.show'); } - Future _invokeOrShow(WindowController controller, String method) async { - try { - await controller.invokeMethod(method); - } on Object { - await controller.show(); + Future _invokeCompactMethod( + WindowController controller, + String method, + ) async { + const attempts = 12; + const retryDelay = Duration(milliseconds: 80); + for (var attempt = 0; attempt < attempts; attempt += 1) { + try { + await controller.invokeMethod(method); + return; + } on Object { + if (attempt == attempts - 1) { + return; + } + await Future.delayed(retryDelay); + } } } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f0a8aef..0b1d518 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -38,6 +38,12 @@ constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kHeaderTooltipVerticalPadding = 5; constexpr gint kHeaderTooltipHorizontalPadding = 8; constexpr gint kHeaderWindowRadius = 8; +constexpr gint kCompactAgendaWindowWidth = 420; +constexpr gint kCompactAgendaWindowHeight = 680; +constexpr gint kCompactAgendaWindowMinWidth = 360; +constexpr gint kCompactAgendaWindowMinHeight = 520; +constexpr gint kCompactAgendaWindowMaxWidth = 480; +constexpr gint kCompactAgendaWindowMaxHeight = 840; constexpr char kDefaultHeaderBarBackgroundColor[] = "#1D1D20"; constexpr char kDefaultHeaderBarSidebarBackgroundColor[] = "#2E2E32"; @@ -2103,6 +2109,46 @@ static void configure_transparent_window_backing(GtkWindow* window) { nullptr); } +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"); + GtkWidget* titlebar = gtk_window_get_titlebar(window); + if (titlebar != nullptr) { + gtk_widget_hide(titlebar); + } + 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); + + 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)); +} + // Called when first Flutter frame received. static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); @@ -2178,7 +2224,10 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); desktop_multi_window_plugin_set_window_created_callback( - [](FlPluginRegistry* registry) { fl_register_plugins(registry); }); + [](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); diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index f0b1928..6ca325b 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -137,9 +137,13 @@ void main() { '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 compactWindowService = File( + 'lib/src/platform/compact_agenda_window_service.dart', + ).readAsStringSync(); expect(pubspec, contains('desktop_multi_window:')); expect(pubspec, contains('window_manager:')); @@ -147,6 +151,18 @@ void main() { runner, contains('desktop_multi_window_plugin_set_window_created_callback'), ); + expect(runner, contains('configure_compact_agenda_subwindow')); + expect(runner, contains('kCompactAgendaWindowWidth = 420')); + expect( + runner, + contains('gtk_window_resize(window, kCompactAgendaWindowWidth'), + ); + expect(runner, contains('gtk_window_get_titlebar(window)')); + expect(runner, contains('gtk_widget_hide(titlebar)')); + 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'))); @@ -158,6 +174,9 @@ void main() { expect(compactApp, isNot(contains('syncSchedulerProvider'))); expect(compactApp, isNot(contains('notificationSchedulerProvider'))); expect(compactApp, isNot(contains('dueTodayNotificationProvider'))); + 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', () { diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 2a987ea..6d383ba 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -92,6 +92,21 @@ void main() { 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 { From 449fb3997dda9e8965d4353ca4ed746cb7aa38fc Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 19:04:59 -0700 Subject: [PATCH 21/53] Enhance compact agenda layout handling and improve window size management --- .../presentation/compact_agenda_panel.dart | 31 ++++++++++--------- linux/runner/my_application.cc | 5 +++ test/app/native_ui_audit_test.dart | 6 ++++ .../compact_agenda_panel_test.dart | 14 +++++++-- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 0031a33..50cb52f 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -23,6 +23,8 @@ 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, @@ -78,19 +80,20 @@ class _CompactAgendaPanelState extends ConsumerState { }, child: Focus( autofocus: true, - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.sm), - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.card, - borderRadius: BorderRadius.circular(BusyMaxRadius.window), - border: Border.all(color: colors.border), - boxShadow: BusyMaxShadow.floatingShadows( - BusyMaxShadow.floatingColor(context), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < + _compactAgendaMinimumLayoutSize.width || + constraints.maxHeight < + _compactAgendaMinimumLayoutSize.height) { + return const SizedBox.expand(); + } + + return DecoratedBox( + decoration: BoxDecoration( + color: colors.card, + border: Border.all(color: colors.border), ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(BusyMaxRadius.window), child: Column( children: [ _CompactAgendaHeader( @@ -106,8 +109,8 @@ class _CompactAgendaPanelState extends ConsumerState { ), ], ), - ), - ), + ); + }, ), ), ), diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 0b1d518..6e1fc7e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2132,12 +2132,17 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { if (titlebar != nullptr) { gtk_widget_hide(titlebar); } + gtk_window_set_decorated(window, FALSE); 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; diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 6ca325b..4a7a1bc 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -159,6 +159,12 @@ void main() { ); 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)')), diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 6d383ba..585f4bb 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -117,10 +117,20 @@ void main() { 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, }) { @@ -128,8 +138,8 @@ Widget _testPanel({ child: localizedTestApp( child: Scaffold( body: SizedBox( - width: 420, - height: 680, + width: size.width, + height: size.height, child: CompactAgendaPanel( data: data, onOpenBusyMax: () async {}, From bcde94a0e29d681951023283f5ede7c35c2653f0 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 19:38:47 -0700 Subject: [PATCH 22/53] Refactor code for improved formatting and consistency in compact agenda components --- lib/src/app/app_bootstrap.dart | 9 +++++---- lib/src/features/calendar/presentation/event_editor.dart | 5 +---- .../schedule/application/compact_agenda_controller.dart | 5 +---- .../calendar/presentation/event_editor_test.dart | 5 +---- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 6869ce7..6302200 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -124,10 +124,11 @@ final linuxHeaderBarServiceProvider = Provider((ref) { return service; }); -final compactAgendaWindowServiceProvider = - Provider((ref) { - return const CompactAgendaWindowService(); - }); +final compactAgendaWindowServiceProvider = Provider( + (ref) { + return const CompactAgendaWindowService(); + }, +); final authRepositoryProvider = Provider((ref) { return AuthRepository( diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 8bc44d1..5f7d987 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -165,10 +165,7 @@ class _EventEditorState extends State { BusyMaxGroupedList( filled: true, children: [ - BusyMaxTimeModeRow( - allDay: _draft.allDay, - onChanged: _setAllDay, - ), + BusyMaxTimeModeRow(allDay: _draft.allDay, onChanged: _setAllDay), ], ), BusyMaxGroupedList( diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart index aca2537..90aff49 100644 --- a/lib/src/features/schedule/application/compact_agenda_controller.dart +++ b/lib/src/features/schedule/application/compact_agenda_controller.dart @@ -17,10 +17,7 @@ class CompactAgendaController { final Ref _ref; - Future setTaskCompleted( - TaskScheduleItem item, - bool completed, - ) async { + Future setTaskCompleted(TaskScheduleItem item, bool completed) async { final fields = { 'status': completed ? 'completed' : 'needsAction', 'completed': completed ? DateTime.now().toUtc().toIso8601String() : null, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 9c1a6c1..9dc3110 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -680,10 +680,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, contains('ScheduleProjection.deterministicSourceColor')); expect(editor, isNot(contains('SourcePicker('))); expect(editor, isNot(contains('labelText: l10n.calendar'))); }, From 2d8cc7b74cbd38b1f5499d65811d14296c3303e6 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 20:23:52 -0700 Subject: [PATCH 23/53] Enhance compact agenda window management with position handling and improved styling --- lib/main.dart | 5 +- lib/src/app/busymax_design.dart | 12 +- .../presentation/compact_agenda_app.dart | 133 ++++++- .../presentation/compact_agenda_panel.dart | 38 +- lib/src/platform/busymax_window_args.dart | 34 +- .../compact_agenda_window_service.dart | 103 ++++- linux/runner/main.cc | 5 + linux/runner/my_application.cc | 367 +++++++++++++++++- pubspec.lock | 2 +- pubspec.yaml | 1 + test/app/native_ui_audit_test.dart | 70 +++- .../compact_agenda_panel_test.dart | 8 + test/platform/busymax_window_args_test.dart | 21 + 13 files changed, 737 insertions(+), 62 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 5193160..5bc4663 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,7 +30,10 @@ Future main(List args) async { runApp( ProviderScope( overrides: overrides, - child: BusyMaxCompactAgendaApp(windowController: windowController), + child: BusyMaxCompactAgendaApp( + windowController: windowController, + windowArgs: windowArgs, + ), ), ); } diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index abf4746..693d824 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -54,14 +54,16 @@ abstract final class BusyMaxSizes { abstract final class BusyMaxElevation { static const double surface = 1; static const double popover = 6; + 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 = 14; static Color floatingColor(BuildContext context) { - return Theme.of(context).shadowColor.withValues(alpha: 0.32); + return BusyMaxSurfaceColors.of(context).shade; } static List floatingShadows(Color color) { @@ -69,6 +71,10 @@ abstract final class BusyMaxShadow { BoxShadow(color: color, blurRadius: floatingBlur, offset: floatingOffset), ]; } + + static List floatingShadowsFor(BuildContext context) { + return floatingShadows(floatingColor(context)); + } } enum BusyMaxPopoverArrowSide { top, bottom } diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index b6e1812..35c17f0 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -11,14 +11,32 @@ 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, super.key}); + const BusyMaxCompactAgendaApp({ + required this.windowController, + required this.windowArgs, + super.key, + }); final WindowController windowController; + final BusyMaxWindowArgs windowArgs; @override ConsumerState createState() => @@ -53,7 +71,7 @@ class _BusyMaxCompactAgendaAppState Future _handleWindowMethodCall(MethodCall call) async { switch (call.method) { case 'busymax.compactAgenda.show': - await _show(); + await _show(call.arguments); return true; case 'busymax.compactAgenda.hide': await widget.windowController.hide(); @@ -64,7 +82,7 @@ class _BusyMaxCompactAgendaAppState if (visible && focused) { await widget.windowController.hide(); } else { - await _show(); + await _show(call.arguments); } return true; case 'busymax.compactAgenda.refresh': @@ -79,21 +97,95 @@ class _BusyMaxCompactAgendaAppState throw MissingPluginException('Not implemented: ${call.method}'); } - Future _show() async { - await widget.windowController.show(); - unawaited(_focusTopRight()); + 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); } - Future _focusTopRight() async { + 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.setAlignment(Alignment.topRight); 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(); @@ -107,24 +199,14 @@ class _BusyMaxCompactAgendaAppState unawaited(widget.windowController.hide()); } - @override - void onWindowBlur() { - unawaited(_hideAfterBlurDelay()); - } - - Future _hideAfterBlurDelay() async { - await Future.delayed(const Duration(milliseconds: 180)); - if (!await _isFocused()) { - await 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) { @@ -136,11 +218,17 @@ class _BusyMaxCompactAgendaAppState 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 [ @@ -150,7 +238,10 @@ class _BusyMaxCompactAgendaAppState supportedLocales: AppLocalizations.supportedLocales, home: const Scaffold( backgroundColor: Colors.transparent, - body: CompactAgendaPanel(), + body: Padding( + padding: EdgeInsets.all(BusyMaxShadow.windowMargin), + child: CompactAgendaPanel(), + ), ), ); }, diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 50cb52f..bce8d4d 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -91,23 +91,33 @@ class _CompactAgendaPanelState extends ConsumerState { return DecoratedBox( decoration: BoxDecoration( - color: colors.card, - border: Border.all(color: colors.border), + borderRadius: BorderRadius.circular(BusyMaxRadius.window), + boxShadow: BusyMaxShadow.floatingShadowsFor(context), ), - child: Column( - children: [ - _CompactAgendaHeader( - data: data.valueOrNull, - onRefresh: _refresh, - onOpenBusyMax: _openBusyMax, - onHide: _hide, + child: ClipRRect( + borderRadius: BorderRadius.circular(BusyMaxRadius.window), + clipBehavior: Clip.antiAliasWithSaveLayer, + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.card, + border: Border.all(color: colors.border), ), - Expanded(child: _body(data)), - _CompactAgendaBottomBar( - onNewTask: _newTask, - onOpenBusyMax: _openBusyMax, + child: Column( + children: [ + _CompactAgendaHeader( + data: data.valueOrNull, + onRefresh: _refresh, + onOpenBusyMax: _openBusyMax, + onHide: _hide, + ), + Expanded(child: _body(data)), + _CompactAgendaBottomBar( + onNewTask: _newTask, + onOpenBusyMax: _openBusyMax, + ), + ], ), - ], + ), ), ); }, diff --git a/lib/src/platform/busymax_window_args.dart b/lib/src/platform/busymax_window_args.dart index 94fab5c..a594408 100644 --- a/lib/src/platform/busymax_window_args.dart +++ b/lib/src/platform/busymax_window_args.dart @@ -3,10 +3,17 @@ import 'dart:convert'; enum BusyMaxWindowKind { main, compactAgenda } class BusyMaxWindowArgs { - const BusyMaxWindowArgs({required this.kind, required this.version}); + 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; @@ -20,11 +27,25 @@ class BusyMaxWindowArgs { 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}, }); } @@ -43,6 +64,17 @@ class BusyMaxWindowArgs { } 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; diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart index de6e7a6..0c21bd2 100644 --- a/lib/src/platform/compact_agenda_window_service.dart +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -1,26 +1,46 @@ 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 = 14.0; +const _compactAgendaOuterWindowSize = Size( + _compactAgendaWindowWidth + _compactAgendaWindowShadowMargin * 2, + _compactAgendaWindowHeight + _compactAgendaWindowShadowMargin * 2, +); + class CompactAgendaWindowService { const CompactAgendaWindowService(); Future toggle() async { + final position = await _preferredCompactAgendaPosition(); final controller = await _findCompactAgendaWindow(); if (controller == null) { - await _createCompactAgendaWindow(); + await _createCompactAgendaWindow(position); return; } - await _invokeCompactMethod(controller, 'busymax.compactAgenda.toggle'); + await _invokeCompactMethod( + controller, + 'busymax.compactAgenda.toggle', + position, + ); } Future show() async { + final position = await _preferredCompactAgendaPosition(); final controller = await _findCompactAgendaWindow(); if (controller == null) { - await _createCompactAgendaWindow(); + await _createCompactAgendaWindow(position); return; } - await _invokeCompactMethod(controller, 'busymax.compactAgenda.show'); + await _invokeCompactMethod( + controller, + 'busymax.compactAgenda.show', + position, + ); } Future hide() async { @@ -50,10 +70,13 @@ class CompactAgendaWindowService { return null; } - Future _createCompactAgendaWindow() async { + Future _createCompactAgendaWindow(Offset position) async { await WindowController.create( WindowConfiguration( - arguments: BusyMaxWindowArgs.compactAgenda.encode(), + arguments: BusyMaxWindowArgs.compactAgendaAt( + x: position.dx, + y: position.dy, + ).encode(), hiddenAtLaunch: true, ), ); @@ -62,12 +85,16 @@ class CompactAgendaWindowService { 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); + await controller.invokeMethod( + method, + _positionMethodArguments(position), + ); return; } on Object { if (attempt == attempts - 1) { @@ -88,4 +115,66 @@ class CompactAgendaWindowService { // 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 left = _clampToVisibleFrame( + visibleFrame.right - _compactAgendaOuterWindowSize.width, + visibleFrame.left, + visibleFrame.right - _compactAgendaOuterWindowSize.width, + ); + final top = _clampToVisibleFrame( + visibleFrame.top, + visibleFrame.top, + visibleFrame.bottom - _compactAgendaOuterWindowSize.height, + ); + return Offset(left, top); + } + + double _clampToVisibleFrame(double value, double min, double max) { + if (max < min) { + return min; + } + return value.clamp(min, max).toDouble(); + } } 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 6e1fc7e..85ec3f4 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 @@ -23,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; @@ -38,12 +41,21 @@ constexpr gint kHeaderMainContentStartInset = kHeaderSidebarContentInset; constexpr gint kHeaderTooltipVerticalPadding = 5; constexpr gint kHeaderTooltipHorizontalPadding = 8; constexpr gint kHeaderWindowRadius = 8; -constexpr gint kCompactAgendaWindowWidth = 420; -constexpr gint kCompactAgendaWindowHeight = 680; -constexpr gint kCompactAgendaWindowMinWidth = 360; -constexpr gint kCompactAgendaWindowMinHeight = 520; -constexpr gint kCompactAgendaWindowMaxWidth = 480; -constexpr gint kCompactAgendaWindowMaxHeight = 840; +constexpr gint kCompactAgendaPanelWidth = 420; +constexpr gint kCompactAgendaPanelHeight = 680; +constexpr gint kCompactAgendaWindowShadowMargin = 14; +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"; @@ -431,7 +443,6 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: transparent;" "background-image: none;" "border: none;" - "box-shadow: none;" "}" ".busymax-titlebar," ".busymax-titlebar:backdrop," @@ -2031,6 +2042,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) { @@ -2109,6 +2311,147 @@ static void configure_transparent_window_backing(GtkWindow* window) { nullptr); } +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 @@ -2128,11 +2471,18 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { 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, @@ -2152,6 +2502,9 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { 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); } // Called when first Flutter frame received. diff --git a/pubspec.lock b/pubspec.lock index a6917cc..be86215 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -917,7 +917,7 @@ packages: source: hosted version: "2.6.1" screen_retriever: - dependency: transitive + dependency: "direct main" description: name: screen_retriever sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" diff --git a/pubspec.yaml b/pubspec.yaml index c6c0cae..d7a7c9d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,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 diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 4a7a1bc..a608c5c 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -133,6 +133,7 @@ void main() { 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(); @@ -141,22 +142,45 @@ void main() { 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('kCompactAgendaWindowWidth = 420')); + 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 = 14')); 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('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)')); @@ -174,12 +198,34 @@ void main() { expect(tray, isNot(contains('BusyMaxTrayAgendaEntry'))); expect(router, isNot(contains('/tray-agenda'))); expect(compactApp, isNot(contains('linux_header_bar_service.dart'))); - expect(compactApp, isNot(contains('gtk_font_service.dart'))); - expect(compactApp, isNot(contains('gtkFontSettingsProvider'))); - expect(compactApp, isNot(contains('gtkThemeColorsProvider'))); + 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.floatingShadowsFor')); expect(compactWindowService, isNot(contains('controller.show()'))); expect(main, isNot(contains('waitUntilReadyToShow'))); expect(main, isNot(contains('await windowManager.show();'))); @@ -187,6 +233,10 @@ void main() { 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( @@ -489,13 +539,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'))); diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 585f4bb..51f511c 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -40,6 +40,14 @@ void main() { expect(find.text('No events or tasks'), 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('task row renders checkbox and calls completion callback', ( tester, ) async { diff --git a/test/platform/busymax_window_args_test.dart b/test/platform/busymax_window_args_test.dart index 3c63181..81ca72b 100644 --- a/test/platform/busymax_window_args_test.dart +++ b/test/platform/busymax_window_args_test.dart @@ -19,6 +19,27 @@ void main() { 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"}', From 576fac0425ead58d86cf536a4720207e31c96e76 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 20:31:11 -0700 Subject: [PATCH 24/53] Update compact agenda shadow margins and enhance window shadow effects --- lib/src/app/busymax_design.dart | 26 +++++++++++++- .../presentation/compact_agenda_panel.dart | 2 +- .../compact_agenda_window_service.dart | 34 +++++++++++-------- linux/runner/my_application.cc | 28 ++------------- test/app/native_ui_audit_test.dart | 26 ++++++++++---- 5 files changed, 68 insertions(+), 48 deletions(-) diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 693d824..bc84247 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -60,7 +60,7 @@ abstract final class BusyMaxElevation { abstract final class BusyMaxShadow { static const double floatingBlur = 24; static const Offset floatingOffset = Offset(0, 8); - static const double windowMargin = 14; + static const double windowMargin = 32; static Color floatingColor(BuildContext context) { return BusyMaxSurfaceColors.of(context).shade; @@ -75,6 +75,30 @@ abstract final class BusyMaxShadow { static List floatingShadowsFor(BuildContext context) { return floatingShadows(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)); + } } enum BusyMaxPopoverArrowSide { top, bottom } diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index bce8d4d..fc355ca 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -92,7 +92,7 @@ class _CompactAgendaPanelState extends ConsumerState { return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(BusyMaxRadius.window), - boxShadow: BusyMaxShadow.floatingShadowsFor(context), + boxShadow: BusyMaxShadow.windowShadowsFor(context), ), child: ClipRRect( borderRadius: BorderRadius.circular(BusyMaxRadius.window), diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart index 0c21bd2..ac322f6 100644 --- a/lib/src/platform/compact_agenda_window_service.dart +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -6,11 +6,8 @@ import 'busymax_window_args.dart'; const _compactAgendaWindowWidth = 420.0; const _compactAgendaWindowHeight = 680.0; -const _compactAgendaWindowShadowMargin = 14.0; -const _compactAgendaOuterWindowSize = Size( - _compactAgendaWindowWidth + _compactAgendaWindowShadowMargin * 2, - _compactAgendaWindowHeight + _compactAgendaWindowShadowMargin * 2, -); +const _compactAgendaWindowShadowMargin = 32.0; +const _compactAgendaPanelScreenGap = 6.0; class CompactAgendaWindowService { const CompactAgendaWindowService(); @@ -158,17 +155,26 @@ class CompactAgendaWindowService { visibleSize.width, visibleSize.height, ); - final left = _clampToVisibleFrame( - visibleFrame.right - _compactAgendaOuterWindowSize.width, - visibleFrame.left, - visibleFrame.right - _compactAgendaOuterWindowSize.width, + final panelLeft = _clampToVisibleFrame( + visibleFrame.right - + _compactAgendaWindowWidth - + _compactAgendaPanelScreenGap, + visibleFrame.left + _compactAgendaPanelScreenGap, + visibleFrame.right - + _compactAgendaWindowWidth - + _compactAgendaPanelScreenGap, ); - final top = _clampToVisibleFrame( - visibleFrame.top, - visibleFrame.top, - visibleFrame.bottom - _compactAgendaOuterWindowSize.height, + final panelTop = _clampToVisibleFrame( + visibleFrame.top + _compactAgendaPanelScreenGap, + visibleFrame.top + _compactAgendaPanelScreenGap, + visibleFrame.bottom - + _compactAgendaWindowHeight - + _compactAgendaPanelScreenGap, + ); + return Offset( + panelLeft - _compactAgendaWindowShadowMargin, + panelTop - _compactAgendaWindowShadowMargin, ); - return Offset(left, top); } double _clampToVisibleFrame(double value, double min, double max) { diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 85ec3f4..80a6754 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,7 +1,6 @@ #include "my_application.h" #include -#include #include #include #include @@ -43,7 +42,7 @@ constexpr gint kHeaderTooltipHorizontalPadding = 8; constexpr gint kHeaderWindowRadius = 8; constexpr gint kCompactAgendaPanelWidth = 420; constexpr gint kCompactAgendaPanelHeight = 680; -constexpr gint kCompactAgendaWindowShadowMargin = 14; +constexpr gint kCompactAgendaWindowShadowMargin = 32; constexpr gint kCompactAgendaWindowWidth = kCompactAgendaPanelWidth + kCompactAgendaWindowShadowMargin * 2; constexpr gint kCompactAgendaWindowHeight = @@ -435,7 +434,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," @@ -647,6 +646,7 @@ static void refresh_header_bar_css(MyApplication* self) { "min-height: 0;" "border-radius: %dpx;" "}", + background_color, background_color, kHeaderWindowRadius, kHeaderWindowRadius, kHeaderWindowRadius, sidebar_background_color, kHeaderWindowRadius, foreground_color, foreground_color, modal_barrier_color, @@ -2290,27 +2290,6 @@ static void register_window_channel(MyApplication* self, FlView* view) { self->window_channel, window_method_call_cb, self, nullptr); } -static gboolean clear_transparent_window_cb(GtkWidget* widget, - cairo_t* cr, - gpointer user_data) { - cairo_save(cr); - cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); - cairo_paint(cr); - cairo_restore(cr); - return FALSE; -} - -static void 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); - } - gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); - g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb), - nullptr); -} - static void install_compact_agenda_window_css(GtkWindow* window) { static const gchar* css = "window#busymax-compact-agenda-window," @@ -2519,7 +2498,6 @@ 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); // 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 diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a608c5c..d1008a5 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -160,7 +160,7 @@ void main() { 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 = 14')); + expect(runner, contains('kCompactAgendaWindowShadowMargin = 32')); expect( runner, contains('gtk_window_resize(window, kCompactAgendaWindowWidth'), @@ -225,7 +225,7 @@ void main() { expect(compactApp, isNot(contains('_hideAfterBlurDelay'))); expect(compactPanel, contains('ClipRRect')); expect(compactPanel, contains('BusyMaxRadius.window')); - expect(compactPanel, contains('BusyMaxShadow.floatingShadowsFor')); + expect(compactPanel, contains('BusyMaxShadow.windowShadowsFor')); expect(compactWindowService, isNot(contains('controller.show()'))); expect(main, isNot(contains('waitUntilReadyToShow'))); expect(main, isNot(contains('await windowManager.show();'))); @@ -360,8 +360,22 @@ void main() { expect(source, contains('kHeaderWindowRadius')); expect(source, contains('border-top-left-radius: %dpx;')); expect(source, contains('border-top-right-radius: %dpx;')); - expect(source, contains('clear_transparent_window_cb')); - expect(source, contains('CAIRO_OPERATOR_CLEAR')); + 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( + mainWindowCss, + isNot(contains('"background-color: transparent;"')), + ); + expect(source, isNot(contains('clear_transparent_window_cb'))); + expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); expect( source, contains('gtk_widget_set_name(GTK_WIDGET(window), "busymax-window")'), @@ -370,9 +384,7 @@ void main() { expect(source, contains('window#busymax-window decoration:backdrop')); expect( source, - contains( - 'g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb)', - ), + isNot(contains('gtk_widget_set_app_paintable(GTK_WIDGET(window)')), ); expect(source, isNot(contains('kHeaderSidebarEdgeCompensation'))); expect(source, isNot(contains('-kHeaderSidebarEdgeCompensation'))); From 1bf5e4b8c324b3c504f9913c555bf52f1662b450 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 21:43:37 -0700 Subject: [PATCH 25/53] Enhance compact agenda with scroll shadows and dynamic background handling --- lib/src/app/busymax_app.dart | 1 + lib/src/app/busymax_design.dart | 20 +++ .../presentation/compact_agenda_panel.dart | 146 +++++++++++++--- .../presentation/schedule_agenda_view.dart | 16 +- .../platform/linux_header_bar_service.dart | 5 + linux/runner/my_application.cc | 159 +++++++++++++++++- test/app/native_ui_audit_test.dart | 28 ++- test/app/theme_localization_test.dart | 19 ++- .../compact_agenda_panel_test.dart | 85 ++++++++++ .../linux_header_bar_service_test.dart | 5 + 10 files changed, 434 insertions(+), 50 deletions(-) diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index e8de3f7..a678078 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -137,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, diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index bc84247..d43e252 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -99,6 +99,26 @@ abstract final class BusyMaxShadow { 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 } diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index fc355ca..74ed39b 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -17,6 +17,7 @@ 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_agenda_view.dart'; import 'schedule_item_details_popover.dart'; import 'schedule_item_exporter.dart'; @@ -51,10 +52,13 @@ class CompactAgendaPanel extends ConsumerStatefulWidget { class _CompactAgendaPanelState extends ConsumerState { final _mutatingTaskKeys = {}; + bool _bodyScrolledUnderHeader = false; + bool _bodyScrolledUnderFooter = false; @override Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); + final colorScheme = Theme.of(context).colorScheme; final AsyncValue data = widget.data ?? ref.watch(compactAgendaDataProvider); return Shortcuts( @@ -110,7 +114,31 @@ class _CompactAgendaPanelState extends ConsumerState { onOpenBusyMax: _openBusyMax, onHide: _hide, ), - Expanded(child: _body(data)), + 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( onNewTask: _newTask, onOpenBusyMax: _openBusyMax, @@ -141,6 +169,7 @@ class _CompactAgendaPanelState extends ConsumerState { ), data: (agenda) { if (!agenda.hasSignedInAccounts) { + _scheduleScrollChromeReset(); return _CompactAgendaMessageState( icon: Icons.login, title: context.l10n.trayAgendaSignInRequired, @@ -149,6 +178,7 @@ class _CompactAgendaPanelState extends ConsumerState { ); } if (!agenda.hasSources) { + _scheduleScrollChromeReset(); return _CompactAgendaMessageState( icon: Icons.event_busy_outlined, title: context.l10n.trayAgendaNoSources, @@ -157,6 +187,7 @@ class _CompactAgendaPanelState extends ConsumerState { ); } if (agenda.items.isEmpty) { + _scheduleScrollChromeReset(); return _CompactAgendaMessageState( icon: Icons.event_available, title: context.l10n.compactAgendaClear, @@ -168,6 +199,50 @@ class _CompactAgendaPanelState extends ConsumerState { ); } + bool _handleScrollNotification(ScrollNotification notification) { + _updateScrollChrome(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; + }); + } + Widget _sections(CompactAgendaData data) { final sections = buildCompactAgendaSections( today: data.today, @@ -327,11 +402,9 @@ class _CompactAgendaHeader extends StatelessWidget { return DragToMoveArea( child: Container( height: 56, + key: const ValueKey('compactAgendaHeader'), padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.md), - decoration: BoxDecoration( - color: colors.headerbarFlat, - border: Border(bottom: BorderSide(color: colors.subtleBorder)), - ), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), child: Row( children: [ Expanded( @@ -558,6 +631,8 @@ class _CompactAgendaSectionView extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final surfaceColors = BusyMaxSurfaceColors.of(context); final title = switch (section.kind) { CompactAgendaSectionKind.overdue => context.l10n.compactAgendaOverdue, CompactAgendaSectionKind.day => compactAgendaDayLabel( @@ -588,11 +663,9 @@ class _CompactAgendaSectionView extends StatelessWidget { ), DecoratedBox( decoration: BoxDecoration( - color: BusyMaxSurfaceColors.of(context).view, + color: colorScheme.surface, borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border.all( - color: BusyMaxSurfaceColors.of(context).subtleBorder, - ), + border: Border.all(color: surfaceColors.subtleBorder), ), child: Column( children: [ @@ -644,6 +717,7 @@ class _CompactAgendaRow extends StatelessWidget { @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + final surfaceColors = BusyMaxSurfaceColors.of(context); final color = ScheduleProjection.colorForItem( item, Theme.of(context).colorScheme.brightness, @@ -661,12 +735,9 @@ class _CompactAgendaRow extends StatelessWidget { child: Container( constraints: const BoxConstraints(minHeight: 62), decoration: BoxDecoration( + color: scheduleAgendaRowBackground(context, item), border: showDivider - ? Border( - bottom: BorderSide( - color: BusyMaxSurfaceColors.of(context).subtleBorder, - ), - ) + ? Border(bottom: BorderSide(color: surfaceColors.subtleBorder)) : null, ), padding: const EdgeInsets.symmetric( @@ -716,7 +787,7 @@ class _CompactAgendaRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BusyMaxSurfaceColors.of(context).mutedForeground, + color: surfaceColors.mutedForeground, ), ), if (event?.location?.trim().isNotEmpty == true) ...[ @@ -726,9 +797,7 @@ class _CompactAgendaRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BusyMaxSurfaceColors.of( - context, - ).mutedForeground, + color: surfaceColors.mutedForeground, ), ), ], @@ -790,13 +859,10 @@ class _CompactAgendaBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = BusyMaxSurfaceColors.of(context); return Container( + key: const ValueKey('compactAgendaFooter'), padding: const EdgeInsets.all(BusyMaxSpacing.md), - decoration: BoxDecoration( - color: colors.headerbarFlat, - border: Border(top: BorderSide(color: colors.subtleBorder)), - ), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), child: Row( children: [ Expanded( @@ -818,6 +884,40 @@ class _CompactAgendaBottomBar extends StatelessWidget { } } +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(); } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 28c9c1e..5c459c2 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -166,12 +166,7 @@ class _AgendaRow extends StatelessWidget { 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, - ), + color: scheduleAgendaRowBackground(context, item), borderRadius: BorderRadius.circular(BusyMaxRadius.sm), border: Border(left: BorderSide(color: color, width: 4)), ), @@ -238,6 +233,15 @@ class _AgendaRow extends StatelessWidget { } } +Color scheduleAgendaRowBackground(BuildContext context, ScheduleItem item) { + final colorScheme = Theme.of(context).colorScheme; + final color = ScheduleProjection.colorForItem(item, colorScheme.brightness); + return Color.alphaBlend( + color.withValues(alpha: item.kind == ScheduleItemKind.task ? 0.08 : 0.12), + colorScheme.surface, + ); +} + String _dayLabel(BuildContext context, DateTime day) { if (DateUtils.isSameDay(day, DateTime.now())) { return context.l10n.today; diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 8030cb4..494b3a1 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, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 80a6754..b4ac843 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,6 +1,7 @@ #include "my_application.h" #include +#include #include #include #include @@ -73,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; @@ -130,6 +132,7 @@ struct _MyApplication { gboolean header_schedule_controls_visible; gboolean header_back_visible; gboolean header_onboarding_controls_visible; + gboolean main_window_transparent_backing; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -384,6 +387,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; @@ -398,6 +421,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 @@ -442,6 +470,7 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: transparent;" "background-image: none;" "border: none;" + "border-radius: %dpx;" "}" ".busymax-titlebar," ".busymax-titlebar:backdrop," @@ -646,7 +675,7 @@ static void refresh_header_bar_css(MyApplication* self) { "min-height: 0;" "border-radius: %dpx;" "}", - background_color, + window_css_background_color, kHeaderWindowRadius, background_color, kHeaderWindowRadius, kHeaderWindowRadius, kHeaderWindowRadius, sidebar_background_color, kHeaderWindowRadius, foreground_color, foreground_color, modal_barrier_color, @@ -698,6 +727,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, @@ -726,7 +757,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); } @@ -2290,6 +2321,118 @@ static void register_window_channel(MyApplication* self, FlView* view) { self->window_channel, window_method_call_cb, self, nullptr); } +static gboolean clear_transparent_window_cb(GtkWidget* widget, + cairo_t* cr, + gpointer user_data) { + cairo_save(cr); + cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR); + cairo_paint(cr); + cairo_restore(cr); + return FALSE; +} + +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) { + 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," @@ -2498,6 +2641,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"); + 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 @@ -2544,11 +2689,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)); @@ -2661,6 +2802,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); @@ -2706,7 +2848,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 = diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index d1008a5..601cfcb 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -371,11 +371,17 @@ void main() { ); expect(mainWindowCss, contains('"background-color: %s;"')); expect( - mainWindowCss, - isNot(contains('"background-color: transparent;"')), + source, + contains('self->main_window_transparent_backing ? "transparent"'), + ); + expect( + source, + contains( + 'g_signal_connect(window, "draw", G_CALLBACK(clear_transparent_window_cb)', + ), ); - expect(source, isNot(contains('clear_transparent_window_cb'))); - expect(source, isNot(contains('CAIRO_OPERATOR_CLEAR'))); + expect(source, contains('clear_transparent_window_cb')); + expect(source, contains('CAIRO_OPERATOR_CLEAR')); expect( source, contains('gtk_widget_set_name(GTK_WIDGET(window), "busymax-window")'), @@ -384,8 +390,13 @@ void main() { expect(source, contains('window#busymax-window decoration:backdrop')); expect( source, - isNot(contains('gtk_widget_set_app_paintable(GTK_WIDGET(window)')), + 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'))); @@ -664,12 +675,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..ceefe9a 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -801,6 +801,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 +813,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( diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 51f511c..2647230 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -1,5 +1,6 @@ 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/features/schedule/presentation/schedule_agenda_view.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'; @@ -81,6 +82,90 @@ void main() { expect(find.byType(YaruCheckbox), findsNothing); }); + testWidgets('row background matches main agenda item surface', ( + tester, + ) async { + final event = _event('Team sync', start: today); + + await tester.pumpWidget(_testPanel(data: _data(today, items: [event]))); + + final itemContext = tester.element(find.text('Team sync')); + final expected = scheduleAgendaRowBackground(itemContext, event); + final rowContainers = tester.widgetList(find.byType(Container)); + + expect( + rowContainers.any((container) { + final decoration = container.decoration; + return decoration is BoxDecoration && decoration.color == expected; + }), + isTrue, + ); + }); + + 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); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 5d474ae..91d272d 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -63,6 +63,7 @@ void main() { await service.setModalBarrierVisible(true); await service.setTheme( const BusyMaxHeaderBarTheme( + windowBackgroundColor: Color(0xFF18181B), backgroundColor: Color(0xFF1D1D20), sidebarBackgroundColor: Color(0xFF2E2E32), foregroundColor: Color(0xFFFFFFFF), @@ -113,6 +114,10 @@ void main() { expect(calls[10].arguments, containsPair('canContinue', true)); expect(calls[10].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'), From ff2b13d2ec5eb194b29d9e8d50953556737584aa Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 21:52:07 -0700 Subject: [PATCH 26/53] Enhance compact agenda styling with box-shadow and outline adjustments --- linux/runner/my_application.cc | 4 +++- test/app/native_ui_audit_test.dart | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index b4ac843..8ae349d 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -470,6 +470,8 @@ static void refresh_header_bar_css(MyApplication* self) { "background-color: transparent;" "background-image: none;" "border: none;" + "box-shadow: 0 3px 18px 2px %s;" + "outline: none;" "border-radius: %dpx;" "}" ".busymax-titlebar," @@ -675,7 +677,7 @@ static void refresh_header_bar_css(MyApplication* self) { "min-height: 0;" "border-radius: %dpx;" "}", - window_css_background_color, kHeaderWindowRadius, + window_css_background_color, shade_color, kHeaderWindowRadius, background_color, kHeaderWindowRadius, kHeaderWindowRadius, kHeaderWindowRadius, sidebar_background_color, kHeaderWindowRadius, foreground_color, foreground_color, modal_barrier_color, diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 601cfcb..1ae578d 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -388,6 +388,9 @@ 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('gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE)'), From 36b9172e7b1cb0cdb86ceb6f8a2e2f5db7811d45 Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 21:59:16 -0700 Subject: [PATCH 27/53] Refactor compact agenda components to use BusyMax grouped action row pattern and improve layout consistency --- .../presentation/compact_agenda_panel.dart | 288 ++++++++---------- .../presentation/schedule_agenda_view.dart | 282 ++++++++--------- test/app/native_ui_audit_test.dart | 175 ++++++----- .../compact_agenda_panel_test.dart | 20 +- 4 files changed, 353 insertions(+), 412 deletions(-) diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 74ed39b..df1b563 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -17,7 +17,6 @@ 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_agenda_view.dart'; import 'schedule_item_details_popover.dart'; import 'schedule_item_exporter.dart'; @@ -631,8 +630,6 @@ class _CompactAgendaSectionView extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final surfaceColors = BusyMaxSurfaceColors.of(context); final title = switch (section.kind) { CompactAgendaSectionKind.overdue => context.l10n.compactAgendaOverdue, CompactAgendaSectionKind.day => compactAgendaDayLabel( @@ -641,57 +638,22 @@ class _CompactAgendaSectionView extends StatelessWidget { day: section.day ?? today, ), }; - return Padding( - padding: const EdgeInsets.only(bottom: BusyMaxSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.xs, - 0, - BusyMaxSpacing.xs, - BusyMaxSpacing.xs, - ), - child: Text( - title, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: BusyMaxSurfaceColors.of(context).mutedForeground, - fontWeight: FontWeight.w700, - ), - ), - ), - DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - border: Border.all(color: surfaceColors.subtleBorder), - ), - child: Column( - children: [ - for (var index = 0; index < section.items.length; index += 1) - _CompactAgendaRow( - item: section.items[index], - today: today, - mutating: - section.items[index] is TaskScheduleItem && - mutatingTaskKeys.contains( - compactAgendaTaskMutationKey( - section.items[index] as TaskScheduleItem, - ), - ), - showDivider: - index < section.items.length - 1 || section.hasMore, - onOpenItem: onOpenItem, - onTaskCompletionChanged: onTaskCompletionChanged, - ), - if (section.hasMore) - _MoreOverdueRow(onOpenBusyMax: onOpenBusyMax), - ], - ), + 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) _MoreOverdueRow(onOpenBusyMax: onOpenBusyMax), + ], ); } } @@ -701,7 +663,6 @@ class _CompactAgendaRow extends StatelessWidget { required this.item, required this.today, required this.mutating, - required this.showDivider, required this.onOpenItem, required this.onTaskCompletionChanged, }); @@ -709,7 +670,6 @@ class _CompactAgendaRow extends StatelessWidget { final ScheduleItem item; final DateTime today; final bool mutating; - final bool showDivider; final Future Function(BuildContext anchorContext, ScheduleItem item) onOpenItem; final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; @@ -717,97 +677,126 @@ class _CompactAgendaRow extends StatelessWidget { @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; - final surfaceColors = BusyMaxSurfaceColors.of(context); + 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, + onTap: () => unawaited(onOpenItem(context, item)), + ), + ); + } +} + +class _CompactAgendaRowMarker extends StatelessWidget { + const _CompactAgendaRowMarker({required this.item}); + + final ScheduleItem item; + + @override + Widget build(BuildContext context) { final color = ScheduleProjection.colorForItem( item, Theme.of(context).colorScheme.brightness, ); + final icon = item.kind == ScheduleItemKind.task + ? YaruIcons.checkbox + : YaruIcons.calendar; + return SizedBox( + width: BusyMaxSizes.iconLg, + height: BusyMaxSizes.iconLg, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + icon, + size: BusyMaxSizes.iconSm, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + Align( + alignment: AlignmentDirectional.bottomEnd, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ), + ], + ), + ); + } +} + +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 AnimatedOpacity( - opacity: mutating ? 0.48 : 1, - duration: const Duration(milliseconds: 120), - child: InkWell( - onTap: mutating ? null : () => unawaited(onOpenItem(context, item)), - child: Container( - constraints: const BoxConstraints(minHeight: 62), - decoration: BoxDecoration( - color: scheduleAgendaRowBackground(context, item), - border: showDivider - ? Border(bottom: BorderSide(color: surfaceColors.subtleBorder)) - : null, - ), - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (task != null) ...[ - YaruCheckbox( - value: task.completed, - onChanged: mutating - ? null - : (value) => unawaited( - onTaskCompletionChanged(task, value ?? false), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - ] else ...[ - Container( - width: 4, - height: 42, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: BusyMaxSpacing.md), - ], - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: BusyMaxSpacing.xxs), - 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) ...[ - const SizedBox(height: BusyMaxSpacing.xxs), - Text( - event!.location!.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: surfaceColors.mutedForeground, - ), - ), - ], - ], - ), - ), - ], - ), + 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, + ), + ), + ], ); } } @@ -819,31 +808,10 @@ class _MoreOverdueRow extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( + return BusyMaxActionRow( + title: context.l10n.compactAgendaMoreOverdue, + leading: const Icon(Icons.open_in_full, size: BusyMaxSizes.iconSm), onTap: () => unawaited(onOpenBusyMax()), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: BusyMaxSpacing.md, - vertical: BusyMaxSpacing.sm, - ), - child: Row( - children: [ - const Icon(Icons.open_in_full, size: BusyMaxSizes.iconSm), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - context.l10n.compactAgendaMoreOverdue, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ), ); } } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 5c459c2..6e54bf2 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -3,6 +3,7 @@ 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'; @@ -47,199 +48,160 @@ class ScheduleAgendaView extends StatelessWidget { 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, - ), - ), - 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, - ), - ), - ], + for (final day in days) + BusyMaxGroupedList( + title: _dayLabel(context, day), + filled: true, + children: [ + for (final item in groups[day]!) + _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, + onTap: (context) => onItemSelected(context, item), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + onTaskCompletionChanged(item, completed) + : null, + ), + ], + ), ], ), ); } } -class _AgendaDayHeader extends StatelessWidget { - const _AgendaDayHeader({required this.day}); +class _AgendaRow extends StatelessWidget { + const _AgendaRow({ + required this.item, + required this.onTap, + this.onTaskCompletionChanged, + }); - final DateTime day; + final ScheduleItem item; + final ValueChanged onTap; + final ValueChanged? onTaskCompletionChanged; @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, + final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + + 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), ), - ), - ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: busyMaxSectionHeaderStyle(context), - ), - ), - ], + onTap: () => onTap(context), ); } } -class _AgendaPlainHeader extends StatelessWidget { - const _AgendaPlainHeader({required this.title}); +class _AgendaItemMarker extends StatelessWidget { + const _AgendaItemMarker({required this.item}); - final String title; + final ScheduleItem item; @override Widget build(BuildContext context) { - return Text(title, style: busyMaxSectionHeaderStyle(context)); + final color = ScheduleProjection.colorForItem( + item, + Theme.of(context).colorScheme.brightness, + ); + final icon = item.kind == ScheduleItemKind.task + ? YaruIcons.checkbox + : YaruIcons.calendar; + return SizedBox( + width: 24, + height: 24, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + icon, + size: BusyMaxSizes.iconSm, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + Align( + alignment: AlignmentDirectional.bottomEnd, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ), + ], + ), + ); } } -class _AgendaRow extends StatelessWidget { - const _AgendaRow({ - required this.item, - required this.onTap, - this.onTaskCompletionChanged, - }); +class _AgendaItemTitle extends StatelessWidget { + const _AgendaItemTitle({required this.item}); final ScheduleItem item; - final ValueChanged onTap; - 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; - - 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: scheduleAgendaRowBackground(context, item), - 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, - ), - ), - ], - ), - ), - ], - ), - ), + 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, ), ); } } -Color scheduleAgendaRowBackground(BuildContext context, ScheduleItem item) { - final colorScheme = Theme.of(context).colorScheme; - final color = ScheduleProjection.colorForItem(item, colorScheme.brightness); - return Color.alphaBlend( - color.withValues(alpha: item.kind == ScheduleItemKind.task ? 0.08 : 0.12), - colorScheme.surface, - ); +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, + ), + ); + } } String _dayLabel(BuildContext context, DateTime day) { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1ae578d..a4d7569 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -28,83 +28,104 @@ 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('showBusyMaxModalEditorDialog')); - expect(newTaskDialog, contains('BusyMaxModalEditorScaffold')); - expect(newTaskDialog, isNot(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('BusyMaxModalEditorScaffold')); + 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('YaruTimeEntry')); + expect(dateTimeFields, isNot(contains('showDatePicker'))); + expect(dateTimeFields, isNot(contains('showTimePicker'))); + }, + ); test('tray DBus menu labels come from injected labels', () { final source = File( diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 2647230..413e146 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -1,6 +1,6 @@ +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/features/schedule/presentation/schedule_agenda_view.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'; @@ -82,24 +82,14 @@ void main() { expect(find.byType(YaruCheckbox), findsNothing); }); - testWidgets('row background matches main agenda item surface', ( - tester, - ) async { + 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]))); - final itemContext = tester.element(find.text('Team sync')); - final expected = scheduleAgendaRowBackground(itemContext, event); - final rowContainers = tester.widgetList(find.byType(Container)); - - expect( - rowContainers.any((container) { - final decoration = container.decoration; - return decoration is BoxDecoration && decoration.color == expected; - }), - isTrue, - ); + 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', ( From 9ca166b50ae6b4dcc0723fc00c9c0e320669388e Mon Sep 17 00:00:00 2001 From: albert Date: Wed, 10 Jun 2026 23:55:28 -0700 Subject: [PATCH 28/53] Refactor compact agenda icon rendering for improved simplicity and performance --- .../presentation/compact_agenda_panel.dart | 23 +------------------ .../presentation/schedule_agenda_view.dart | 23 +------------------ 2 files changed, 2 insertions(+), 44 deletions(-) diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index df1b563..8393840 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -716,28 +716,7 @@ class _CompactAgendaRowMarker extends StatelessWidget { final icon = item.kind == ScheduleItemKind.task ? YaruIcons.checkbox : YaruIcons.calendar; - return SizedBox( - width: BusyMaxSizes.iconLg, - height: BusyMaxSizes.iconLg, - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - icon, - size: BusyMaxSizes.iconSm, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - Align( - alignment: AlignmentDirectional.bottomEnd, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - ), - ], - ), - ); + return Icon(icon, size: BusyMaxSizes.iconSm, color: color); } } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 6e54bf2..930c213 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -133,28 +133,7 @@ class _AgendaItemMarker extends StatelessWidget { final icon = item.kind == ScheduleItemKind.task ? YaruIcons.checkbox : YaruIcons.calendar; - return SizedBox( - width: 24, - height: 24, - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - icon, - size: BusyMaxSizes.iconSm, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - Align( - alignment: AlignmentDirectional.bottomEnd, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - ), - ], - ), - ); + return Icon(icon, size: BusyMaxSizes.iconSm, color: color); } } From 0456a9235af731751dfa5bf795089fc340d5fb61 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 00:18:18 -0700 Subject: [PATCH 29/53] Add tooltip support to compact agenda items with position tracking --- lib/src/app/busymax_design.dart | 49 ++++++- lib/src/app/busymax_yaru_theme.dart | 2 +- .../presentation/compact_agenda_panel.dart | 23 +++- .../presentation/schedule_agenda_view.dart | 15 ++- .../presentation/schedule_day_week_view.dart | 25 ++-- .../presentation/schedule_event_block.dart | 9 +- .../presentation/schedule_item_chip.dart | 3 +- .../schedule_item_details_popover.dart | 108 ++++++++++----- .../presentation/schedule_item_selection.dart | 13 ++ .../presentation/schedule_month_view.dart | 8 +- .../presentation/schedule_more_popover.dart | 8 +- .../presentation/schedule_task_chip.dart | 11 +- .../presentation/schedule_workspace.dart | 43 +++++- linux/runner/my_application.cc | 3 +- test/app/native_ui_audit_test.dart | 1 + test/app/theme_localization_test.dart | 4 +- .../presentation/schedule_views_test.dart | 125 ++++++++++++++++-- 17 files changed, 367 insertions(+), 83 deletions(-) create mode 100644 lib/src/features/schedule/presentation/schedule_item_selection.dart diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index d43e252..541098e 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -54,6 +54,7 @@ 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; } @@ -62,20 +63,49 @@ abstract final class BusyMaxShadow { 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 BusyMaxSurfaceColors.of(context).shade; } + static Color tooltipColor(BuildContext context) { + return _scaleAlpha(floatingColor(context), 1.45); + } + static List floatingShadows(Color color) { return [ 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( @@ -148,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( @@ -824,6 +854,7 @@ class BusyMaxActionRow extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onPointerDown, this.enabled = true, this.tooltip, this.destructive = false, @@ -838,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; @@ -870,13 +902,22 @@ 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)), ); } } 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/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 8393840..b74df7d 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -307,7 +307,11 @@ class _CompactAgendaPanelState extends ConsumerState { await windowManager.hide(); } - Future _openItem(BuildContext anchorContext, ScheduleItem item) async { + Future _openItem( + BuildContext anchorContext, + ScheduleItem item, [ + Offset? globalPosition, + ]) async { final callback = widget.onOpenItem; if (callback != null) { await callback(item); @@ -317,6 +321,7 @@ class _CompactAgendaPanelState extends ConsumerState { context: context, anchorContext: anchorContext, item: item, + anchorPoint: globalPosition, ); if (!mounted || action == null) { return; @@ -623,7 +628,11 @@ class _CompactAgendaSectionView extends StatelessWidget { final CompactAgendaSection section; final DateTime today; final Set mutatingTaskKeys; - final Future Function(BuildContext anchorContext, ScheduleItem item) + final Future Function( + BuildContext anchorContext, + ScheduleItem item, [ + Offset? globalPosition, + ]) onOpenItem; final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; final Future Function() onOpenBusyMax; @@ -670,13 +679,18 @@ class _CompactAgendaRow extends StatelessWidget { final ScheduleItem item; final DateTime today; final bool mutating; - final Future Function(BuildContext anchorContext, ScheduleItem item) + 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), @@ -696,7 +710,8 @@ class _CompactAgendaRow extends StatelessWidget { ), ), enabled: !mutating, - onTap: () => unawaited(onOpenItem(context, item)), + onPointerDown: (position) => pointerDownPosition = position, + onTap: () => unawaited(onOpenItem(context, item, pointerDownPosition)), ), ); } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 930c213..c9056fe 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -9,6 +9,7 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; import 'schedule_event_block.dart'; +import 'schedule_item_selection.dart'; class ScheduleAgendaView extends StatelessWidget { const ScheduleAgendaView({ @@ -21,7 +22,7 @@ class ScheduleAgendaView extends StatelessWidget { 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; @@ -56,7 +57,8 @@ class ScheduleAgendaView extends StatelessWidget { for (final item in groups[day]!) _AgendaRow( item: item, - onTap: (context) => onItemSelected(context, item), + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => onTaskCompletionChanged(item, completed) @@ -72,7 +74,8 @@ class ScheduleAgendaView extends StatelessWidget { for (final item in noDateTasks) _AgendaRow( item: item, - onTap: (context) => onItemSelected(context, item), + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => onTaskCompletionChanged(item, completed) @@ -94,12 +97,13 @@ class _AgendaRow extends StatelessWidget { }); final ScheduleItem item; - final ValueChanged onTap; + final ScheduleItemTapCallback onTap; final ValueChanged? onTaskCompletionChanged; @override Widget build(BuildContext context) { final task = item is TaskScheduleItem ? item as TaskScheduleItem : null; + Offset? pointerDownPosition; return BusyMaxActionRow( title: item.title, @@ -114,7 +118,8 @@ class _AgendaRow extends StatelessWidget { ? null : (value) => onTaskCompletionChanged!(value ?? false), ), - onTap: () => onTap(context), + onPointerDown: (position) => pointerDownPosition = position, + onTap: () => onTap(context, pointerDownPosition), ); } } 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 3955b27..ddf2c15 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -12,6 +12,7 @@ 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; @@ -38,7 +39,7 @@ 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; @@ -168,7 +169,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) @@ -215,7 +217,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, @@ -396,7 +399,7 @@ class _FullDayScrollPane extends StatefulWidget { final List events; final double height; final double width; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -470,7 +473,7 @@ class _FullDayEventTile extends StatelessWidget { final icv.Event event; final double width; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -501,7 +504,8 @@ class _FullDayEventTile extends StatelessWidget { height: 24, width: width, compact: true, - onTap: (context) => onItemSelected(context, item), + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => onTaskCompletionChanged(item, completed) : null, @@ -587,7 +591,7 @@ class _SameSlotItemsStrip extends StatefulWidget { final double height; final double width; final bool compact; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -646,8 +650,11 @@ class _SameSlotItemsStripState extends State<_SameSlotItemsStrip> { height: height, width: chipWidth, compact: widget.compact, - onTap: (context) => - widget.onItemSelected(context, widget.items[index]), + onTap: (context, [globalPosition]) => widget.onItemSelected( + context, + widget.items[index], + globalPosition, + ), onTaskCompletionChanged: widget.items[index] is TaskScheduleItem ? (completed) => widget.onTaskCompletionChanged( 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 426d175..b903945 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}); 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..4e52caf --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_item_selection.dart @@ -0,0 +1,13 @@ +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]); diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 7e28aaf..de8b1d7 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -8,6 +8,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 +30,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 +140,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; @@ -215,7 +216,8 @@ class _MonthDayCell extends StatelessWidget { item: item, height: 22, compact: true, - onTap: (context) => onItemSelected(context, item), + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem ? (completed) => onTaskCompletionChanged(item, completed) 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_task_chip.dart b/lib/src/features/schedule/presentation/schedule_task_chip.dart index 679b9d9..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 @@ -53,6 +54,7 @@ class ScheduleTaskChip extends StatelessWidget { 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), @@ -63,7 +65,12 @@ 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: horizontalPadding, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 8af12e5..cc82a54 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -35,6 +35,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'; @@ -237,6 +238,7 @@ class _ScheduleWorkspaceState extends ConsumerState { visibility.hasTaskLists, items: items, onDaySelected: _setDate, + onYearDaySelected: _openDay, onMonthSelected: _setMonth, onEmptySlot: (start) => unawaited( _openCreateChoice(accounts, visibleSources, start), @@ -254,8 +256,15 @@ class _ScheduleWorkspaceState extends ConsumerState { onNewTask: () => unawaited(_openNewTask(accounts)), onPrevious: _previous, onNext: _next, - onItemSelected: (context, item) => - unawaited(_openItem(context, item, visibleSources)), + onItemSelected: (context, item, [globalPosition]) => + unawaited( + _openItem( + context, + item, + visibleSources, + globalPosition: globalPosition, + ), + ), onTaskCompletionChanged: _setTaskCompleted, ), ), @@ -293,7 +302,7 @@ class _ScheduleWorkspaceState extends ConsumerState { selectedDate: _selectedDate, firstWeekday: firstWeekday, items: miniCalendarItems, - onDateSelected: _setDate, + onDateSelected: _openDay, onMonthSelected: _setMonth, onYearSelected: _setYear, onWeekSelected: _setWeek, @@ -526,6 +535,22 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } + 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) { setState(() { if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { @@ -732,12 +757,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; @@ -1136,6 +1163,7 @@ class _ScheduleBody extends StatelessWidget { required this.hasAnySources, required this.items, required this.onDaySelected, + required this.onYearDaySelected, required this.onMonthSelected, required this.onEmptySlot, required this.onCreateAtDay, @@ -1155,6 +1183,7 @@ class _ScheduleBody extends StatelessWidget { final bool hasAnySources; final List items; final ValueChanged onDaySelected; + final ValueChanged onYearDaySelected; final ValueChanged onMonthSelected; final ValueChanged onEmptySlot; final ValueChanged onCreateAtDay; @@ -1162,7 +1191,7 @@ class _ScheduleBody extends StatelessWidget { final VoidCallback onNewTask; final VoidCallback onPrevious; final VoidCallback onNext; - final void Function(BuildContext context, ScheduleItem item) onItemSelected; + final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -1216,7 +1245,7 @@ class _ScheduleBody extends StatelessWidget { selectedDate: selectedDate, items: items, firstWeekday: firstWeekday, - onDaySelected: onDaySelected, + onDaySelected: onYearDaySelected, onMonthSelected: onMonthSelected, onCreateAtDay: onCreateAtDay, ), diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 8ae349d..653b506 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -664,6 +664,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 {" @@ -693,7 +694,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; diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a4d7569..d0d238e 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -433,6 +433,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( diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index ceefe9a..98f8335 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -143,7 +143,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 +152,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); diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 8c40247..e666c65 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: (_, _) {}, ), ), @@ -92,7 +93,7 @@ void main() { items: _sameSlotItemsFor(selectedDate), onDaySelected: (_) {}, onEmptySlot: (_) {}, - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -133,7 +134,7 @@ void main() { items: _manyAllDayItemsFor(selectedDate), onDaySelected: (_) {}, onEmptySlot: (_) {}, - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -233,7 +234,7 @@ void main() { items: _itemsFor(selectedDate), onDaySelected: (_) {}, onCreateAtDay: (_) {}, - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -262,7 +263,7 @@ void main() { child: ScheduleItemChip( item: event, height: 34, - onTap: (_) => selectedItem = event, + onTap: (_, [_]) => selectedItem = event, ), ), ), @@ -362,6 +363,100 @@ void main() { expect(find.text('Categories: Home, Work'), 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 { @@ -480,7 +575,7 @@ void main() { sourceName: 'Inbox', ), ], - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -509,7 +604,7 @@ void main() { child: ScheduleAgendaView( range: ScheduleRange.week(selectedDate), items: const [], - onItemSelected: (_, _) {}, + onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, ), ), @@ -867,7 +962,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(); @@ -888,20 +983,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', From 247152d51a7e88871c288dc07080cd5009f23a58 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 00:49:29 -0700 Subject: [PATCH 30/53] Add schedule settings for configurable day start and end times in agenda --- lib/l10n/app_de.arb | 5 + lib/l10n/app_en.arb | 5 + lib/l10n/app_es.arb | 5 + lib/l10n/app_fr.arb | 5 + lib/l10n/generated/app_localizations.dart | 30 ++++ lib/l10n/generated/app_localizations_de.dart | 16 ++ lib/l10n/generated/app_localizations_en.dart | 16 ++ lib/l10n/generated/app_localizations_es.dart | 16 ++ lib/l10n/generated/app_localizations_fr.dart | 16 ++ lib/src/app/app_settings.dart | 98 ++++++++++++ .../presentation/schedule_day_week_view.dart | 141 +++++++++++++++++- .../presentation/schedule_workspace.dart | 10 ++ .../presentation/settings_screen.dart | 60 +++++++- .../presentation/schedule_views_test.dart | 55 +++++++ .../presentation/settings_screen_test.dart | 27 ++++ 15 files changed, 501 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2837593..cb8f485 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -67,6 +67,11 @@ "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 e961ee5..c6f5a1e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -69,6 +69,11 @@ "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 67ed99b..503cc53 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -67,6 +67,11 @@ "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 1f61d6c..e056590 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -67,6 +67,11 @@ "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 468f604..1d524ca 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -504,6 +504,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 f76658b..59906b5 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -222,6 +222,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 b5b0e9a..77c5edb 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -219,6 +219,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 3e6f079..f9528e6 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -223,6 +223,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 28c9c64..bd41dad 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -223,6 +223,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/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 6c1f3a7..186e0f7 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() { @@ -71,11 +77,31 @@ 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, + ); return AppSettings( themeFamily: _enumFromName( BusyMaxThemeFamily.values, @@ -132,6 +158,8 @@ class AppSettings { json['scheduleViewMode'], defaults.scheduleViewMode, ), + scheduleDayStartMinute: scheduleDayStartMinute, + scheduleDayEndMinute: scheduleDayEndMinute, ); } @@ -155,6 +183,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 +210,8 @@ class AppSettings { 'lastDueTodayNotificationDate': lastDueTodayNotificationDate, 'taskListScheduleVisibility': taskListScheduleVisibility, 'scheduleViewMode': scheduleViewMode.name, + 'scheduleDayStartMinute': scheduleDayStartMinute, + 'scheduleDayEndMinute': scheduleDayEndMinute, }; } @@ -204,8 +236,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 +277,8 @@ class AppSettings { taskListScheduleVisibility: taskListScheduleVisibility ?? this.taskListScheduleVisibility, scheduleViewMode: scheduleViewMode ?? this.scheduleViewMode, + scheduleDayStartMinute: resolvedScheduleDayStartMinute, + scheduleDayEndMinute: resolvedScheduleDayEndMinute, ); } @@ -295,6 +340,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)); } @@ -416,6 +485,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/features/schedule/presentation/schedule_day_week_view.dart b/lib/src/features/schedule/presentation/schedule_day_week_view.dart index ddf2c15..55ff44d 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -4,6 +4,7 @@ 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'; @@ -19,6 +20,7 @@ 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({ @@ -31,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; @@ -42,6 +46,8 @@ class ScheduleDayWeekView extends StatefulWidget { final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; + final int dayStartMinute; + final int dayEndMinute; @override State createState() => _ScheduleDayWeekViewState(); @@ -51,6 +57,7 @@ class _ScheduleDayWeekViewState extends State { late final icv.EventsController _controller; final _plannerKey = GlobalKey(); var _fullDayBarHeight = _fullDayBarDefaultHeight; + var _heightPerMinute = _defaultHeightPerMinute; @override void initState() { @@ -72,6 +79,10 @@ class _ScheduleDayWeekViewState extends State { !_sameDay(_plannerStartDate(oldWidget), _plannerStartDate(widget))) { _jumpToDate(_plannerStartDate(widget)); } + if (oldWidget.dayStartMinute != widget.dayStartMinute || + oldWidget.dayEndMinute != widget.dayEndMinute) { + _jumpToVisibleDayStart(); + } } @override @@ -92,6 +103,7 @@ class _ScheduleDayWeekViewState extends State { final showFullDayBar = _hasRenderedFullDayEvents(context, widget); final fullDayBarHeight = showFullDayBar ? _fullDayBarHeight : 0.0; final daysHeaderHeight = widget.daysShowed == 1 ? 0.0 : 50.0; + final visibleMinutes = _visibleMinuteRange(widget); final planner = icv.EventsPlanner( key: _plannerKey, @@ -100,8 +112,8 @@ class _ScheduleDayWeekViewState extends State { 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, @@ -257,14 +269,30 @@ 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) { @@ -322,6 +350,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) { @@ -336,6 +376,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}); diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index cc82a54..a4a83d9 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -233,6 +233,8 @@ class _ScheduleWorkspaceState extends ConsumerState { ? displayRange.start : _selectedDate, firstWeekday: _firstWeekday(context), + dayStartMinute: settings.scheduleDayStartMinute, + dayEndMinute: settings.scheduleDayEndMinute, hasAnySources: visibility.hasCalendarSources || visibility.hasTaskLists, @@ -1160,6 +1162,8 @@ 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, @@ -1180,6 +1184,8 @@ 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; @@ -1208,6 +1214,8 @@ class _ScheduleBody extends StatelessWidget { range: range, selectedDate: selectedDate, daysShowed: 1, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, items: items, onDaySelected: onDaySelected, onEmptySlot: onEmptySlot, @@ -1218,6 +1226,8 @@ class _ScheduleBody extends StatelessWidget { range: range, selectedDate: selectedDate, daysShowed: 7, + dayStartMinute: dayStartMinute, + dayEndMinute: dayEndMinute, items: items, onDaySelected: onDaySelected, onEmptySlot: onEmptySlot, 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/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index e666c65..6300eba 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -53,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 { 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 { From b6d118db447f6c93f64c90b9fa25e696490f0571 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 01:10:21 -0700 Subject: [PATCH 31/53] Refactor compact agenda item icons to use task list icons and improve color handling --- .../presentation/compact_agenda_panel.dart | 15 +++++---- .../presentation/schedule_agenda_view.dart | 15 +++++---- .../presentation/schedule_sidebar.dart | 20 +++++------ .../presentation/schedule_workspace.dart | 6 ++-- .../presentation/schedule_views_test.dart | 33 +++++++++++++++++++ 5 files changed, 62 insertions(+), 27 deletions(-) diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index b74df7d..930b8b6 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -724,13 +724,14 @@ class _CompactAgendaRowMarker extends StatelessWidget { @override Widget build(BuildContext context) { - final color = ScheduleProjection.colorForItem( - item, - Theme.of(context).colorScheme.brightness, - ); - final icon = item.kind == ScheduleItemKind.task - ? YaruIcons.checkbox - : YaruIcons.calendar; + 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); } } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index c9056fe..baed34c 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -131,13 +131,14 @@ class _AgendaItemMarker extends StatelessWidget { @override Widget build(BuildContext context) { - final color = ScheduleProjection.colorForItem( - item, - Theme.of(context).colorScheme.brightness, - ); - final icon = item.kind == ScheduleItemKind.task - ? YaruIcons.checkbox - : YaruIcons.calendar; + 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); } } 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_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index a4a83d9..8765575 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -489,9 +489,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(const Duration(days: 7)), ), }; } diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 6300eba..534c966 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1083,6 +1083,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'))); @@ -1204,6 +1206,37 @@ void main() { expect(source, contains('onNext: onNext')); }); + test('agenda range starts at the selected date', () { + final source = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(source, contains('ScheduleViewMode.agenda => ScheduleRange(')); + expect(source, contains('start: _day(_selectedDate)')); + expect( + source, + contains('end: _day(_selectedDate).add(const Duration(days: 7))'), + ); + expect( + source, + isNot(contains('ScheduleViewMode.agenda => ScheduleRange.week')), + ); + }); + + 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('year mode uses existing schedule primitives', () { final mode = File( 'lib/src/schedule/schedule_view_mode.dart', From a797ecdc5f1cab051dbb24881e87875941737661 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 01:44:38 -0700 Subject: [PATCH 32/53] Add support for no-date tasks in compact agenda sections and improve task querying --- .../application/compact_agenda_data.dart | 49 ++-- .../application/compact_agenda_sections.dart | 16 +- .../presentation/compact_agenda_panel.dart | 107 ++++---- .../presentation/schedule_agenda_view.dart | 22 ++ .../presentation/schedule_day_week_view.dart | 1 + .../presentation/schedule_month_view.dart | 229 ++++++++++++------ .../presentation/schedule_workspace.dart | 103 ++++++-- .../compact_agenda_sections_test.dart | 24 +- .../compact_agenda_panel_test.dart | 25 +- .../presentation/schedule_views_test.dart | 114 ++++++++- 10 files changed, 525 insertions(+), 165 deletions(-) diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart index 9947f0a..9700f73 100644 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ b/lib/src/features/schedule/application/compact_agenda_data.dart @@ -13,7 +13,6 @@ final compactAgendaDataProvider = FutureProvider.autoDispose( final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final end = today.add(const Duration(days: 7)); - final queryStart = today.subtract(const Duration(days: 30)); final range = ScheduleRange(start: today, end: end); CompactAgendaData empty({ @@ -62,27 +61,41 @@ final compactAgendaDataProvider = FutureProvider.autoDispose( return empty(hasSignedInAccounts: true, hasSources: false); } - final rawItems = await ref - .read(scheduleRepositoryProvider) - .listItems( - range: ScheduleRange(start: queryStart, end: end), - filters: ScheduleFilters( - accountIds: accountIds, - sourceIds: visibility.visibleCalendarSourceIds, - taskListIds: visibility.visibleTaskListIds, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: true, - includeTasks: true, - showCompletedTasks: false, - showNoDateTasks: false, - ), - ); + final repository = ref.read(scheduleRepositoryProvider); + final rawItemLists = await Future.wait([ + 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: true, + ), + ), + repository.listItems( + range: ScheduleRange(start: DateTime(1), end: today), + filters: ScheduleFilters( + accountIds: accountIds, + taskListIds: visibility.visibleTaskListIds, + taskListFilterActive: true, + includeCalendarEvents: false, + includeTasks: true, + showCompletedTasks: false, + showNoDateTasks: false, + ), + ), + ]); + final rawItems = rawItemLists.expand((items) => items); final items = rawItems.where((item) { final start = item.start; if (start == null) { - return false; + return item is TaskScheduleItem && !item.completed; } if (item is CalendarScheduleItem) { return !start.isBefore(today) && start.isBefore(end); diff --git a/lib/src/features/schedule/application/compact_agenda_sections.dart b/lib/src/features/schedule/application/compact_agenda_sections.dart index 3dbef5a..ca018b6 100644 --- a/lib/src/features/schedule/application/compact_agenda_sections.dart +++ b/lib/src/features/schedule/application/compact_agenda_sections.dart @@ -2,7 +2,7 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_sorting.dart'; -enum CompactAgendaSectionKind { overdue, day } +enum CompactAgendaSectionKind { overdue, day, noDate } class CompactAgendaSection { const CompactAgendaSection({ @@ -41,6 +41,7 @@ List buildCompactAgendaSections({ } final grouped = >{}; + final noDateTasks = []; final end = today.add(const Duration(days: 7)); for (final item in items) { if (item is TaskScheduleItem && item.completed) { @@ -48,6 +49,9 @@ List buildCompactAgendaSections({ } final start = item.start; if (start == null) { + if (item is TaskScheduleItem) { + noDateTasks.add(item); + } continue; } final day = ScheduleProjection.day(start); @@ -69,5 +73,15 @@ List buildCompactAgendaSections({ ); } + if (noDateTasks.isNotEmpty) { + noDateTasks.sort(compareScheduleItems); + sections.add( + CompactAgendaSection( + kind: CompactAgendaSectionKind.noDate, + items: noDateTasks, + ), + ); + } + return sections; } diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 930b8b6..429d443 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -559,58 +559,70 @@ class _CompactAgendaMessageState extends StatelessWidget { @override Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); - return Center( - child: Padding( - padding: const EdgeInsets.all(BusyMaxSpacing.xl), - 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, + 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: [ - if (primaryLabel != null) - BusyMaxPushButton.filled( - onPressed: onPrimary == null - ? null - : () => unawaited(onPrimary!()), - child: Text(primaryLabel!), + 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 (secondaryLabel != null) - BusyMaxPushButton.outlined( - onPressed: onSecondary == null - ? null - : () => unawaited(onSecondary!()), - child: Text(secondaryLabel!), + ), + 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!), + ), + ], + ), + ], ], ), - ], - ], - ), - ), + ), + ), + ); + }, ); } } @@ -646,6 +658,7 @@ class _CompactAgendaSectionView extends StatelessWidget { today: today, day: section.day ?? today, ), + CompactAgendaSectionKind.noDate => context.l10n.noDate, }; return BusyMaxGroupedList( title: title, diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index baed34c..8d42622 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -8,6 +8,7 @@ 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'; @@ -33,6 +34,12 @@ class ScheduleAgendaView extends StatelessWidget { final groups = ScheduleProjection.groupByDay(dated); final rangeStart = ScheduleProjection.day(range.start); final rangeEnd = ScheduleProjection.day(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)) @@ -49,6 +56,21 @@ class ScheduleAgendaView extends StatelessWidget { BusyMaxSpacing.xl, ), children: [ + if (overdueTasks.isNotEmpty) + BusyMaxGroupedList( + title: context.l10n.overdue, + filled: true, + children: [ + for (final item in overdueTasks) + _AgendaRow( + item: item, + onTap: (context, [globalPosition]) => + onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: (completed) => + onTaskCompletionChanged(item, completed), + ), + ], + ), for (final day in days) BusyMaxGroupedList( title: _dayLabel(context, day), 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 55ff44d..2ca6c81 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -63,6 +63,7 @@ class _ScheduleDayWeekViewState extends State { void initState() { super.initState(); _controller = icv.EventsController(); + _jumpToVisibleDayStart(); } @override diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index de8b1d7..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'; @@ -148,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 @@ -161,90 +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, [globalPosition]) => - onItemSelected(context, item, globalPosition), - 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_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 8765575..47b2c49 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -21,6 +21,7 @@ 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'; @@ -141,23 +142,14 @@ 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, - ), - ), + 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 && @@ -167,10 +159,14 @@ class _ScheduleWorkspaceState extends ConsumerState { sourcesLoading || taskListsLoading || itemsLoading; - final items = ScheduleProjection.filterByScope( + final scopedItems = ScheduleProjection.filterByScope( snapshot.data ?? const [], _scope, ); + final items = + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _agendaItems(scopedItems, range) + : scopedItems; _latestItems = items; final miniCalendarItemsFuture = ref .watch(scheduleRepositoryProvider) @@ -515,6 +511,77 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } + Future> _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: true, + ), + ); + if (searchHasQuery || _mode != ScheduleViewMode.agenda) { + return currentItems; + } + + final overdueTasks = repository.listItems( + range: _allOverdueTasksRange(range), + filters: ScheduleFilters( + accountIds: accountIds, + taskListIds: taskListIds, + taskListFilterActive: true, + includeCalendarEvents: false, + includeTasks: _scope != ScheduleScope.events, + showCompletedTasks: false, + showNoDateTasks: false, + ), + ); + final results = await Future.wait([currentItems, overdueTasks]); + return [...results[0], ...results[1]]; + } + + ScheduleRange _allOverdueTasksRange(ScheduleRange displayRange) { + return ScheduleRange(start: DateTime(1), end: displayRange.start); + } + + 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 { diff --git a/test/features/schedule/application/compact_agenda_sections_test.dart b/test/features/schedule/application/compact_agenda_sections_test.dart index 9ed2ca6..43b9ffd 100644 --- a/test/features/schedule/application/compact_agenda_sections_test.dart +++ b/test/features/schedule/application/compact_agenda_sections_test.dart @@ -1,3 +1,5 @@ +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'; @@ -87,11 +89,31 @@ void main() { expect(sections.single.items, hasLength(8)); expect(sections.single.hasMore, isTrue); }); + + 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('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: true')); + expect(source, contains('ScheduleRange(start: DateTime(1), end: today)')); + expect(source, contains('includeCalendarEvents: false')); + }); } TaskScheduleItem _task( String title, { - required DateTime start, + DateTime? start, bool completed = false, }) { return TaskScheduleItem( diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 413e146..89cca5f 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -34,6 +34,20 @@ void main() { 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))); @@ -41,6 +55,15 @@ void main() { expect(find.text('No events or tasks'), findsOneWidget); }); + 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))); @@ -259,7 +282,7 @@ AsyncValue _data( ); } -TaskScheduleItem _task(String title, {required DateTime start}) { +TaskScheduleItem _task(String title, {DateTime? start}) { return TaskScheduleItem( id: title, accountId: 'account', diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 534c966..bd12ac9 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -302,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 { @@ -608,6 +635,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( @@ -616,8 +644,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', @@ -639,6 +714,10 @@ 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); @@ -1164,6 +1243,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)')); }); @@ -1221,6 +1306,33 @@ void main() { source, isNot(contains('ScheduleViewMode.agenda => ScheduleRange.week')), ); + expect(source, contains('ScheduleRange _allOverdueTasksRange')); + expect(source, contains('start: DateTime(1)')); + expect(source, contains('end: displayRange.start')); + expect(source, isNot(contains('subtract(const Duration(days: 30))'))); + }); + + test('agenda queries overdue tasks separately from current events', () { + final source = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + + expect(source, contains('Future> _scheduleItems')); + expect(source, contains('final currentItems = repository.listItems')); + expect(source, contains('final overdueTasks = repository.listItems')); + expect(source, contains('range: _allOverdueTasksRange(range)')); + expect(source, contains('includeCalendarEvents: false')); + expect(source, contains('showCompletedTasks: false')); + expect(source, contains('showNoDateTasks: false')); + expect(source, contains('Future.wait([currentItems, overdueTasks])')); + 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', () { From a9abf5f20edd3c99b2717ee065ec24acd6ed1c94 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 01:58:49 -0700 Subject: [PATCH 33/53] Implement load more functionality in agenda view and enhance navigation visibility handling --- .../presentation/schedule_agenda_view.dart | 164 +++++++++++------- .../presentation/schedule_toolbar.dart | 27 +-- .../presentation/schedule_workspace.dart | 88 ++++++++-- .../platform/linux_header_bar_service.dart | 12 ++ linux/runner/my_application.cc | 20 ++- .../presentation/schedule_views_test.dart | 101 ++++++++++- .../linux_header_bar_service_test.dart | 9 +- 7 files changed, 319 insertions(+), 102 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 8d42622..33009fb 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -12,13 +12,14 @@ 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.onLoadMore, }); final ScheduleRange range; @@ -26,14 +27,46 @@ class ScheduleAgendaView extends StatelessWidget { final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; + final VoidCallback? onLoadMore; + + @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 > 480) { + 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 && @@ -46,66 +79,69 @@ class ScheduleAgendaView extends StatelessWidget { .toList() ..sort(); - return ColoredBox( - color: Theme.of(context).colorScheme.surface, - child: ListView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.lg, - BusyMaxSpacing.md, - BusyMaxSpacing.lg, - BusyMaxSpacing.xl, + 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, + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: (completed) => + widget.onTaskCompletionChanged(item, completed), + ), + ], + ), + for (final day in days) + BusyMaxGroupedList( + title: _dayLabel(context, day), + filled: true, + children: [ + for (final item in groups[day]!) + _AgendaRow( + item: item, + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + widget.onTaskCompletionChanged(item, completed) + : null, + ), + ], + ), + if (noDateTasks.isNotEmpty) + BusyMaxGroupedList( + title: context.l10n.noDate, + filled: true, + children: [ + for (final item in noDateTasks) + _AgendaRow( + item: item, + onTap: (context, [globalPosition]) => + widget.onItemSelected(context, item, globalPosition), + onTaskCompletionChanged: item is TaskScheduleItem + ? (completed) => + widget.onTaskCompletionChanged(item, completed) + : null, + ), + ], + ), + ], ), - children: [ - if (overdueTasks.isNotEmpty) - BusyMaxGroupedList( - title: context.l10n.overdue, - filled: true, - children: [ - for (final item in overdueTasks) - _AgendaRow( - item: item, - onTap: (context, [globalPosition]) => - onItemSelected(context, item, globalPosition), - onTaskCompletionChanged: (completed) => - onTaskCompletionChanged(item, completed), - ), - ], - ), - for (final day in days) - BusyMaxGroupedList( - title: _dayLabel(context, day), - filled: true, - children: [ - for (final item in groups[day]!) - _AgendaRow( - item: item, - onTap: (context, [globalPosition]) => - onItemSelected(context, item, globalPosition), - 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, - onTap: (context, [globalPosition]) => - onItemSelected(context, item, globalPosition), - onTaskCompletionChanged: item is TaskScheduleItem - ? (completed) => - onTaskCompletionChanged(item, completed) - : null, - ), - ], - ), - ], ), ); } 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 47b2c49..da4333a 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -52,6 +52,9 @@ class ScheduleWorkspace extends ConsumerStatefulWidget { } class _ScheduleWorkspaceState extends ConsumerState { + static const _agendaInitialDays = 30; + static const _agendaPageDays = 30; + var _selectedDate = DateTime.now(); var _mode = ScheduleViewMode.week; late ScheduleScope _scope; @@ -66,6 +69,7 @@ class _ScheduleWorkspaceState extends ConsumerState { final _searchFocusNode = FocusNode(); var _latestCanShowSidebar = false; var _latestItems = const []; + var _agendaLoadedDays = _agendaInitialDays; ScheduleViewMode? _lastSettingsMode; _HeaderBarStateSnapshot? _lastHeaderBarState; @@ -254,6 +258,10 @@ class _ScheduleWorkspaceState extends ConsumerState { onNewTask: () => unawaited(_openNewTask(accounts)), onPrevious: _previous, onNext: _next, + onAgendaLoadMore: + !searchHasQuery && _mode == ScheduleViewMode.agenda + ? _loadMoreAgendaDays + : null, onItemSelected: (context, item, [globalPosition]) => unawaited( _openItem( @@ -389,6 +397,7 @@ class _ScheduleWorkspaceState extends ConsumerState { canRefresh: accounts.isNotEmpty, searchActive: _searchActive, sidebarVisible: sidebarVisible, + navigationVisible: _mode != ScheduleViewMode.agenda, ); if (_lastHeaderBarState == headerBarState) { return; @@ -412,6 +421,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)); @@ -431,8 +441,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); @@ -471,7 +487,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) { @@ -487,7 +503,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ScheduleViewMode.year => ScheduleRange.year(_selectedDate), ScheduleViewMode.agenda => ScheduleRange( start: _day(_selectedDate), - end: _day(_selectedDate).add(const Duration(days: 7)), + end: _day(_selectedDate).add(Duration(days: _agendaLoadedDays)), ), }; } @@ -601,6 +617,9 @@ class _ScheduleWorkspaceState extends ConsumerState { _scope = ScheduleScope.all; } _selectedDate = _day(date); + if (_mode == ScheduleViewMode.agenda) { + _resetAgendaLoadedDays(); + } }); } @@ -692,6 +711,10 @@ class _ScheduleWorkspaceState extends ConsumerState { return; } if (previousSettingsMode == null || _mode == previousSettingsMode) { + if (_mode != ScheduleViewMode.agenda && + settingsMode == ScheduleViewMode.agenda) { + _resetAgendaLoadedDays(); + } _mode = settingsMode; } } @@ -699,6 +722,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; } @@ -712,6 +738,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; } @@ -741,11 +770,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, @@ -756,6 +789,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate.month, _selectedDate.day, ), + ScheduleViewMode.agenda => _selectedDate, }; if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; @@ -764,11 +798,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, @@ -779,6 +815,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate.month, _selectedDate.day, ), + ScheduleViewMode.agenda => _selectedDate, }; if (_scope == ScheduleScope.today || _scope == ScheduleScope.upcoming) { _scope = ScheduleScope.all; @@ -786,6 +823,19 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } + void _loadMoreAgendaDays() { + if (_mode != ScheduleViewMode.agenda) { + return; + } + setState(() { + _agendaLoadedDays += _agendaPageDays; + }); + } + + void _resetAgendaLoadedDays() { + _agendaLoadedDays = _agendaInitialDays; + } + Future _openCreateChoice( List accounts, List sources, @@ -1121,6 +1171,7 @@ class _ScheduleWorkspaceState extends ConsumerState { _selectedDate = _day(date); _mode = ScheduleViewMode.agenda; _lastSettingsMode = ScheduleViewMode.agenda; + _resetAgendaLoadedDays(); }); unawaited( ref @@ -1193,6 +1244,7 @@ class _HeaderBarStateSnapshot { required this.canRefresh, required this.searchActive, required this.sidebarVisible, + required this.navigationVisible, }); final String titleRange; @@ -1200,6 +1252,7 @@ class _HeaderBarStateSnapshot { final bool canRefresh; final bool searchActive; final bool sidebarVisible; + final bool navigationVisible; @override bool operator ==(Object other) { @@ -1209,7 +1262,8 @@ class _HeaderBarStateSnapshot { viewMode == other.viewMode && canRefresh == other.canRefresh && searchActive == other.searchActive && - sidebarVisible == other.sidebarVisible; + sidebarVisible == other.sidebarVisible && + navigationVisible == other.navigationVisible; } @override @@ -1219,6 +1273,7 @@ class _HeaderBarStateSnapshot { canRefresh, searchActive, sidebarVisible, + navigationVisible, ); } @@ -1242,6 +1297,7 @@ class _ScheduleBody extends StatelessWidget { required this.onNewTask, required this.onPrevious, required this.onNext, + required this.onAgendaLoadMore, required this.onItemSelected, required this.onTaskCompletionChanged, }); @@ -1264,6 +1320,7 @@ class _ScheduleBody extends StatelessWidget { final VoidCallback onNewTask; final VoidCallback onPrevious; final VoidCallback onNext; + final VoidCallback? onAgendaLoadMore; final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -1327,15 +1384,12 @@ class _ScheduleBody extends StatelessWidget { 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, + onLoadMore: onAgendaLoadMore, + onItemSelected: onItemSelected, + onTaskCompletionChanged: onTaskCompletionChanged, ), }; } @@ -1443,7 +1497,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/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index 494b3a1..468252c 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -239,6 +239,7 @@ class LinuxHeaderBarService { bool? _canCreate; bool? _searchActive; bool? _sidebarVisible; + bool? _navigationVisible; bool? _scheduleControlsVisible; bool? _backVisible; _BusyMaxOnboardingControlsState? _onboardingControls; @@ -356,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/linux/runner/my_application.cc b/linux/runner/my_application.cc index 653b506..528da93 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -130,6 +130,7 @@ 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; @@ -1250,14 +1251,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); @@ -1642,6 +1654,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); @@ -2909,6 +2924,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/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index bd12ac9..4c1920d 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -751,6 +751,52 @@ void main() { expect(find.text('New task'), findsNothing); }); + testWidgets('agenda view asks for more items near 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); + }); + test('schedule presentation does not use banned package final UI', () { final files = Directory( 'lib/src/features/schedule/presentation', @@ -1268,7 +1314,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(); @@ -1285,23 +1331,32 @@ 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 the selected date', () { + test('agenda range starts at selected date and grows while scrolling', () { final source = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); + expect(source, contains('static const _agendaInitialDays = 30')); + expect(source, contains('static const _agendaPageDays = 30')); + expect(source, contains('var _agendaLoadedDays = _agendaInitialDays')); expect(source, contains('ScheduleViewMode.agenda => ScheduleRange(')); expect(source, contains('start: _day(_selectedDate)')); expect( source, - contains('end: _day(_selectedDate).add(const Duration(days: 7))'), + 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')), @@ -1312,6 +1367,44 @@ void main() { expect(source, isNot(contains('subtract(const Duration(days: 30))'))); }); + 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 overdue tasks separately from current events', () { final source = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 91d272d..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, @@ -94,6 +95,7 @@ void main() { 'setSidebarWidth', 'setSearchActive', 'setSidebarVisible', + 'setNavigationVisible', 'setBackVisible', 'setOnboardingControls', 'setModalBarrierVisible', @@ -110,9 +112,10 @@ 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, From b54128709ec0a7a602899bc7e281aa58d5793b40 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 02:36:32 -0700 Subject: [PATCH 34/53] Add load more functionality for overdue and no-date tasks in compact agenda --- lib/l10n/app_de.arb | 8 +- lib/l10n/app_en.arb | 8 +- lib/l10n/app_es.arb | 8 +- lib/l10n/app_fr.arb | 8 +- lib/l10n/generated/app_localizations.dart | 18 +- lib/l10n/generated/app_localizations_de.dart | 13 +- lib/l10n/generated/app_localizations_en.dart | 12 +- lib/l10n/generated/app_localizations_es.dart | 12 +- lib/l10n/generated/app_localizations_fr.dart | 13 +- .../compact_agenda_controller.dart | 1 + .../application/compact_agenda_data.dart | 266 +++++++++------ .../application/compact_agenda_sections.dart | 44 ++- .../presentation/compact_agenda_app.dart | 2 + .../presentation/compact_agenda_panel.dart | 146 ++++++--- .../presentation/schedule_agenda_view.dart | 50 ++- .../presentation/schedule_workspace.dart | 105 +++++- lib/src/schedule/schedule_repository.dart | 306 +++++++++++++++--- .../compact_agenda_sections_test.dart | 79 ++++- .../compact_agenda_panel_test.dart | 91 +++++- .../presentation/schedule_views_test.dart | 116 ++++++- .../schedule/schedule_search_test.dart | 128 ++++++++ 21 files changed, 1164 insertions(+), 270 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cb8f485..7fabbe3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -49,9 +49,9 @@ "trayAgendaRefresh": "Aktualisieren", "trayAgendaError": "Agenda nicht verfügbar", "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Nächste 7 Tage", + "compactAgendaSubtitle": "Anstehend", "compactAgendaOverdue": "Überfällig", - "compactAgendaClear": "Frei für die nächsten 7 Tage", + "compactAgendaClear": "Im Moment frei", "compactAgendaOpenBusyMax": "BusyMax öffnen", "compactAgendaHide": "Ausblenden", "compactAgendaNewTask": "Neue Aufgabe", @@ -61,7 +61,9 @@ "compactAgendaDueToday": "Heute fällig", "compactAgendaDueTomorrow": "Morgen fällig", "compactAgendaDueOn": "Fällig {date}", - "compactAgendaMoreOverdue": "Weitere überfällige Aufgaben in BusyMax", + "compactAgendaMoreOverdue": "Weitere überfällige Aufgaben laden", + "agendaLoadMoreOverdue": "Weitere überfällige Aufgaben laden", + "agendaLoadMoreNoDate": "Weitere Aufgaben ohne Datum laden", "viewDay": "Tag", "viewWeek": "Woche", "viewMonth": "Monat", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c6f5a1e..bbddff6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -50,9 +50,9 @@ "trayAgendaRefresh": "Refresh", "trayAgendaError": "Agenda unavailable", "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Next 7 days", + "compactAgendaSubtitle": "Upcoming", "compactAgendaOverdue": "Overdue", - "compactAgendaClear": "Clear for the next 7 days", + "compactAgendaClear": "Clear for now", "compactAgendaOpenBusyMax": "Open BusyMax", "compactAgendaHide": "Hide", "compactAgendaNewTask": "New task", @@ -63,7 +63,9 @@ "compactAgendaDueTomorrow": "Due tomorrow", "compactAgendaDueOn": "Due {date}", "@compactAgendaDueOn": {"placeholders": {"date": {"type": "String"}}}, - "compactAgendaMoreOverdue": "More overdue tasks in BusyMax", + "compactAgendaMoreOverdue": "Load more overdue tasks", + "agendaLoadMoreOverdue": "Load more overdue tasks", + "agendaLoadMoreNoDate": "Load more no-date tasks", "viewDay": "Day", "viewWeek": "Week", "viewMonth": "Month", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 503cc53..8afe71e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -49,9 +49,9 @@ "trayAgendaRefresh": "Actualizar", "trayAgendaError": "Agenda no disponible", "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "Próximos 7 días", + "compactAgendaSubtitle": "Próximamente", "compactAgendaOverdue": "Vencidas", - "compactAgendaClear": "Libre durante los próximos 7 días", + "compactAgendaClear": "Libre por ahora", "compactAgendaOpenBusyMax": "Abrir BusyMax", "compactAgendaHide": "Ocultar", "compactAgendaNewTask": "Nueva tarea", @@ -61,7 +61,9 @@ "compactAgendaDueToday": "Vence hoy", "compactAgendaDueTomorrow": "Vence mañana", "compactAgendaDueOn": "Vence {date}", - "compactAgendaMoreOverdue": "Más tareas vencidas en BusyMax", + "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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index e056590..872d430 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -49,9 +49,9 @@ "trayAgendaRefresh": "Actualiser", "trayAgendaError": "Agenda indisponible", "compactAgendaTitle": "Agenda", - "compactAgendaSubtitle": "7 prochains jours", + "compactAgendaSubtitle": "À venir", "compactAgendaOverdue": "En retard", - "compactAgendaClear": "Libre pour les 7 prochains jours", + "compactAgendaClear": "Libre pour le moment", "compactAgendaOpenBusyMax": "Ouvrir BusyMax", "compactAgendaHide": "Masquer", "compactAgendaNewTask": "Nouvelle tâche", @@ -61,7 +61,9 @@ "compactAgendaDueToday": "Échéance aujourd’hui", "compactAgendaDueTomorrow": "Échéance demain", "compactAgendaDueOn": "Échéance {date}", - "compactAgendaMoreOverdue": "Plus de tâches en retard dans BusyMax", + "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", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 1d524ca..6e53b70 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -399,7 +399,7 @@ abstract class AppLocalizations { /// No description provided for @compactAgendaSubtitle. /// /// In en, this message translates to: - /// **'Next 7 days'** + /// **'Upcoming'** String get compactAgendaSubtitle; /// No description provided for @compactAgendaOverdue. @@ -411,7 +411,7 @@ abstract class AppLocalizations { /// No description provided for @compactAgendaClear. /// /// In en, this message translates to: - /// **'Clear for the next 7 days'** + /// **'Clear for now'** String get compactAgendaClear; /// No description provided for @compactAgendaOpenBusyMax. @@ -471,9 +471,21 @@ abstract class AppLocalizations { /// No description provided for @compactAgendaMoreOverdue. /// /// In en, this message translates to: - /// **'More overdue tasks in BusyMax'** + /// **'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: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 59906b5..b16aa50 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -166,13 +166,13 @@ class AppLocalizationsDe extends AppLocalizations { String get compactAgendaTitle => 'Agenda'; @override - String get compactAgendaSubtitle => 'Nächste 7 Tage'; + String get compactAgendaSubtitle => 'Anstehend'; @override String get compactAgendaOverdue => 'Überfällig'; @override - String get compactAgendaClear => 'Frei für die nächsten 7 Tage'; + String get compactAgendaClear => 'Im Moment frei'; @override String get compactAgendaOpenBusyMax => 'BusyMax öffnen'; @@ -204,8 +204,13 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get compactAgendaMoreOverdue => - 'Weitere überfällige Aufgaben in BusyMax'; + 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'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 77c5edb..42c6ae6 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -164,13 +164,13 @@ class AppLocalizationsEn extends AppLocalizations { String get compactAgendaTitle => 'Agenda'; @override - String get compactAgendaSubtitle => 'Next 7 days'; + String get compactAgendaSubtitle => 'Upcoming'; @override String get compactAgendaOverdue => 'Overdue'; @override - String get compactAgendaClear => 'Clear for the next 7 days'; + String get compactAgendaClear => 'Clear for now'; @override String get compactAgendaOpenBusyMax => 'Open BusyMax'; @@ -202,7 +202,13 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get compactAgendaMoreOverdue => 'More overdue tasks in BusyMax'; + 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'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index f9528e6..7f2d628 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -168,13 +168,13 @@ class AppLocalizationsEs extends AppLocalizations { String get compactAgendaTitle => 'Agenda'; @override - String get compactAgendaSubtitle => 'Próximos 7 días'; + String get compactAgendaSubtitle => 'Próximamente'; @override String get compactAgendaOverdue => 'Vencidas'; @override - String get compactAgendaClear => 'Libre durante los próximos 7 días'; + String get compactAgendaClear => 'Libre por ahora'; @override String get compactAgendaOpenBusyMax => 'Abrir BusyMax'; @@ -206,7 +206,13 @@ class AppLocalizationsEs extends AppLocalizations { } @override - String get compactAgendaMoreOverdue => 'Más tareas vencidas en BusyMax'; + 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'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index bd41dad..5b206a6 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -167,13 +167,13 @@ class AppLocalizationsFr extends AppLocalizations { String get compactAgendaTitle => 'Agenda'; @override - String get compactAgendaSubtitle => '7 prochains jours'; + String get compactAgendaSubtitle => 'À venir'; @override String get compactAgendaOverdue => 'En retard'; @override - String get compactAgendaClear => 'Libre pour les 7 prochains jours'; + String get compactAgendaClear => 'Libre pour le moment'; @override String get compactAgendaOpenBusyMax => 'Ouvrir BusyMax'; @@ -205,8 +205,13 @@ class AppLocalizationsFr extends AppLocalizations { } @override - String get compactAgendaMoreOverdue => - 'Plus de tâches en retard dans BusyMax'; + 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'; diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart index 90aff49..781675c 100644 --- a/lib/src/features/schedule/application/compact_agenda_controller.dart +++ b/lib/src/features/schedule/application/compact_agenda_controller.dart @@ -37,6 +37,7 @@ class CompactAgendaController { } _ref.invalidate(compactAgendaDataProvider); + _ref.invalidate(compactAgendaDataForQueryProvider); } } diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart index 9700f73..994d183 100644 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ b/lib/src/features/schedule/application/compact_agenda_data.dart @@ -7,121 +7,158 @@ 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'; -final compactAgendaDataProvider = FutureProvider.autoDispose( - (ref) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final end = today.add(const Duration(days: 7)); - final range = ScheduleRange(start: today, end: end); - - CompactAgendaData empty({ - required bool hasSignedInAccounts, - required bool hasSources, - }) { - return CompactAgendaData( - today: today, - range: range, - items: const [], - hasSignedInAccounts: hasSignedInAccounts, - hasSources: hasSources, - generatedAt: now, - ); - } - - final accounts = await ref - .read(accountsRepositoryProvider) - .listSignedInAccounts(); - if (accounts.isEmpty) { - return empty(hasSignedInAccounts: false, hasSources: false); - } +const compactAgendaInitialDays = 30; +const compactAgendaPageDays = 30; - 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 compactAgendaDataProvider = FutureProvider.autoDispose( + (ref) { + return ref.watch( + compactAgendaDataForQueryProvider(CompactAgendaQuery.initial).future, ); - final hasSources = - visibility.visibleCalendarSourceIds.isNotEmpty || - visibility.visibleTaskListIds.isNotEmpty; - if (!hasSources) { - return empty(hasSignedInAccounts: true, hasSources: false); - } + }, +); - final repository = ref.read(scheduleRepositoryProvider); - final rawItemLists = await Future.wait([ - 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: true, - ), - ), - repository.listItems( - range: ScheduleRange(start: DateTime(1), end: today), - filters: ScheduleFilters( - accountIds: accountIds, - taskListIds: visibility.visibleTaskListIds, - taskListFilterActive: true, - includeCalendarEvents: false, - includeTasks: true, - showCompletedTasks: false, - showNoDateTasks: false, - ), - ), - ]); - final rawItems = rawItemLists.expand((items) => 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); +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: items, - hasSignedInAccounts: true, - hasSources: true, + 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, @@ -130,7 +167,38 @@ class CompactAgendaData { 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 index ca018b6..ead417d 100644 --- a/lib/src/features/schedule/application/compact_agenda_sections.dart +++ b/lib/src/features/schedule/application/compact_agenda_sections.dart @@ -2,6 +2,11 @@ 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 { @@ -21,7 +26,19 @@ class CompactAgendaSection { 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 && @@ -34,15 +51,15 @@ List buildCompactAgendaSections({ sections.add( CompactAgendaSection( kind: CompactAgendaSectionKind.overdue, - items: overdueTasks.take(8).toList(), - hasMore: overdueTasks.length > 8, + items: overdueTasks.take(visibleOverdueLimit).toList(), + hasMore: + hasMoreOverdueTasks || overdueTasks.length > visibleOverdueLimit, ), ); } final grouped = >{}; final noDateTasks = []; - final end = today.add(const Duration(days: 7)); for (final item in items) { if (item is TaskScheduleItem && item.completed) { continue; @@ -55,30 +72,31 @@ List buildCompactAgendaSections({ continue; } final day = ScheduleProjection.day(start); - if (day.isBefore(today) || !day.isBefore(end)) { + if (day.isBefore(today) || (rangeEnd != null && !day.isBefore(rangeEnd))) { continue; } grouped.putIfAbsent(day, () => []).add(item); } final days = grouped.keys.toList()..sort(); - for (final day in days) { - final dayItems = grouped[day]!..sort(compareScheduleItems); + if (noDateTasks.isNotEmpty) { + noDateTasks.sort(compareScheduleItems); sections.add( CompactAgendaSection( - kind: CompactAgendaSectionKind.day, - day: day, - items: dayItems, + kind: CompactAgendaSectionKind.noDate, + items: noDateTasks.take(visibleNoDateLimit).toList(), + hasMore: hasMoreNoDateTasks || noDateTasks.length > visibleNoDateLimit, ), ); } - if (noDateTasks.isNotEmpty) { - noDateTasks.sort(compareScheduleItems); + for (final day in days) { + final dayItems = grouped[day]!..sort(compareScheduleItems); sections.add( CompactAgendaSection( - kind: CompactAgendaSectionKind.noDate, - items: noDateTasks, + kind: CompactAgendaSectionKind.day, + day: day, + items: dayItems, ), ); } diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index 35c17f0..70064cf 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -87,6 +87,7 @@ class _BusyMaxCompactAgendaAppState return true; case 'busymax.compactAgenda.refresh': ref.invalidate(compactAgendaDataProvider); + ref.invalidate(compactAgendaDataForQueryProvider); return true; case 'busymax.compactAgenda.destroy': await windowManager.setPreventClose(false); @@ -106,6 +107,7 @@ class _BusyMaxCompactAgendaAppState unawaited(_focusNearTrayArea()); } ref.invalidate(compactAgendaDataProvider); + ref.invalidate(compactAgendaDataForQueryProvider); } Future _showNativeWindow(Offset? position) async { diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 429d443..a2a6128 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -51,6 +51,12 @@ class CompactAgendaPanel extends ConsumerStatefulWidget { 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; @@ -58,8 +64,19 @@ class _CompactAgendaPanelState extends ConsumerState { Widget build(BuildContext context) { final colors = BusyMaxSurfaceColors.of(context); final colorScheme = Theme.of(context).colorScheme; - final AsyncValue data = - widget.data ?? ref.watch(compactAgendaDataProvider); + 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(), @@ -110,7 +127,6 @@ class _CompactAgendaPanelState extends ConsumerState { _CompactAgendaHeader( data: data.valueOrNull, onRefresh: _refresh, - onOpenBusyMax: _openBusyMax, onHide: _hide, ), Expanded( @@ -138,10 +154,7 @@ class _CompactAgendaPanelState extends ConsumerState { ], ), ), - _CompactAgendaBottomBar( - onNewTask: _newTask, - onOpenBusyMax: _openBusyMax, - ), + _CompactAgendaBottomBar(onNewTask: _newTask), ], ), ), @@ -200,6 +213,7 @@ class _CompactAgendaPanelState extends ConsumerState { bool _handleScrollNotification(ScrollNotification notification) { _updateScrollChrome(notification.metrics); + _maybeLoadMore(notification.metrics); return false; } @@ -242,10 +256,32 @@ class _CompactAgendaPanelState extends ConsumerState { }); } + 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( @@ -263,12 +299,25 @@ class _CompactAgendaPanelState extends ConsumerState { mutatingTaskKeys: _mutatingTaskKeys, onOpenItem: _openItem, onTaskCompletionChanged: _setTaskCompleted, - onOpenBusyMax: _openBusyMax, + onLoadMoreOverdue: _loadMoreOverdue, + onLoadMoreNoDate: _loadMoreNoDate, ); }, ); } + void _loadMoreOverdue() { + setState(() { + _overdueLimit += compactAgendaOverduePageSize; + }); + } + + void _loadMoreNoDate() { + setState(() { + _noDateLimit += compactAgendaNoDatePageSize; + }); + } + Future _openBusyMax() async { final callback = widget.onOpenBusyMax; if (callback != null) { @@ -295,7 +344,7 @@ class _CompactAgendaPanelState extends ConsumerState { await callback(); return; } - ref.invalidate(compactAgendaDataProvider); + _invalidateAgendaData(); } Future _hide() async { @@ -369,6 +418,7 @@ class _CompactAgendaPanelState extends ConsumerState { .read(compactAgendaControllerProvider) .setTaskCompleted(item, completed); } + _invalidateAgendaData(); } on Object catch (error) { if (mounted) { ScaffoldMessenger.of( @@ -381,19 +431,30 @@ class _CompactAgendaPanelState extends ConsumerState { } } } + + void _invalidateAgendaData() { + ref.invalidate(compactAgendaDataProvider); + ref.invalidate(compactAgendaDataForQueryProvider(_query)); + } + + CompactAgendaQuery get _query { + return CompactAgendaQuery( + futureDays: _loadedDays, + overdueLimit: _overdueLimit, + noDateLimit: _noDateLimit, + ); + } } class _CompactAgendaHeader extends StatelessWidget { const _CompactAgendaHeader({ required this.data, required this.onRefresh, - required this.onOpenBusyMax, required this.onHide, }); final CompactAgendaData? data; final Future Function() onRefresh; - final Future Function() onOpenBusyMax; final Future Function() onHide; @override @@ -440,15 +501,11 @@ class _CompactAgendaHeader extends StatelessWidget { icon: Icons.refresh, onPressed: () => unawaited(onRefresh()), ), - _CompactHeaderButton( - tooltip: context.l10n.compactAgendaOpenBusyMax, - icon: Icons.open_in_full, - onPressed: () => unawaited(onOpenBusyMax()), - ), - _CompactHeaderButton( - tooltip: context.l10n.compactAgendaHide, - icon: Icons.close, - onPressed: () => unawaited(onHide()), + const SizedBox(width: BusyMaxSpacing.xs), + YaruWindowControl( + type: YaruWindowControlType.close, + semanticLabel: context.l10n.compactAgendaHide, + onTap: () => unawaited(onHide()), ), ], ), @@ -634,7 +691,8 @@ class _CompactAgendaSectionView extends StatelessWidget { required this.mutatingTaskKeys, required this.onOpenItem, required this.onTaskCompletionChanged, - required this.onOpenBusyMax, + required this.onLoadMoreOverdue, + required this.onLoadMoreNoDate, }); final CompactAgendaSection section; @@ -647,7 +705,8 @@ class _CompactAgendaSectionView extends StatelessWidget { ]) onOpenItem; final CompactAgendaTaskCompletionCallback onTaskCompletionChanged; - final Future Function() onOpenBusyMax; + final VoidCallback onLoadMoreOverdue; + final VoidCallback onLoadMoreNoDate; @override Widget build(BuildContext context) { @@ -674,7 +733,22 @@ class _CompactAgendaSectionView extends StatelessWidget { onOpenItem: onOpenItem, onTaskCompletionChanged: onTaskCompletionChanged, ), - if (section.hasMore) _MoreOverdueRow(onOpenBusyMax: onOpenBusyMax), + 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, + }, + ), ], ); } @@ -809,29 +883,26 @@ class _CompactAgendaRowSubtitle extends StatelessWidget { } } -class _MoreOverdueRow extends StatelessWidget { - const _MoreOverdueRow({required this.onOpenBusyMax}); +class _MoreBucketRow extends StatelessWidget { + const _MoreBucketRow({required this.title, required this.onLoadMore}); - final Future Function() onOpenBusyMax; + final String title; + final VoidCallback onLoadMore; @override Widget build(BuildContext context) { return BusyMaxActionRow( - title: context.l10n.compactAgendaMoreOverdue, - leading: const Icon(Icons.open_in_full, size: BusyMaxSizes.iconSm), - onTap: () => unawaited(onOpenBusyMax()), + title: title, + leading: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), + onTap: onLoadMore, ); } } class _CompactAgendaBottomBar extends StatelessWidget { - const _CompactAgendaBottomBar({ - required this.onNewTask, - required this.onOpenBusyMax, - }); + const _CompactAgendaBottomBar({required this.onNewTask}); final Future Function() onNewTask; - final Future Function() onOpenBusyMax; @override Widget build(BuildContext context) { @@ -847,13 +918,6 @@ class _CompactAgendaBottomBar extends StatelessWidget { child: Text(context.l10n.compactAgendaNewTask), ), ), - const SizedBox(width: BusyMaxSpacing.sm), - Expanded( - child: BusyMaxPushButton.outlined( - onPressed: () => unawaited(onOpenBusyMax()), - child: Text(context.l10n.compactAgendaOpenBusyMax), - ), - ), ], ), ); diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 33009fb..1bf7725 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -19,7 +19,11 @@ class ScheduleAgendaView extends StatefulWidget { required this.items, required this.onItemSelected, required this.onTaskCompletionChanged, + this.hasMoreOverdueTasks = false, + this.hasMoreNoDateTasks = false, this.onLoadMore, + this.onLoadMoreOverdue, + this.onLoadMoreNoDate, }); final ScheduleRange range; @@ -27,7 +31,11 @@ class ScheduleAgendaView extends StatefulWidget { 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; @override State createState() => _ScheduleAgendaViewState(); @@ -52,7 +60,7 @@ class _ScheduleAgendaViewState extends State { if (notification.metrics.axis != Axis.vertical) { return false; } - if (notification.metrics.extentAfter > 480) { + if (notification.metrics.extentAfter > 1) { return false; } _loadMoreArmed = false; @@ -104,14 +112,20 @@ class _ScheduleAgendaViewState extends State { onTaskCompletionChanged: (completed) => widget.onTaskCompletionChanged(item, completed), ), + if (widget.hasMoreOverdueTasks && + widget.onLoadMoreOverdue != null) + _AgendaLoadMoreRow( + title: context.l10n.agendaLoadMoreOverdue, + onTap: widget.onLoadMoreOverdue!, + ), ], ), - for (final day in days) + if (noDateTasks.isNotEmpty) BusyMaxGroupedList( - title: _dayLabel(context, day), + title: context.l10n.noDate, filled: true, children: [ - for (final item in groups[day]!) + for (final item in noDateTasks) _AgendaRow( item: item, onTap: (context, [globalPosition]) => @@ -121,14 +135,20 @@ class _ScheduleAgendaViewState extends State { widget.onTaskCompletionChanged(item, completed) : null, ), + if (widget.hasMoreNoDateTasks && + widget.onLoadMoreNoDate != null) + _AgendaLoadMoreRow( + title: context.l10n.agendaLoadMoreNoDate, + onTap: widget.onLoadMoreNoDate!, + ), ], ), - if (noDateTasks.isNotEmpty) + for (final day in days) BusyMaxGroupedList( - title: context.l10n.noDate, + title: _dayLabel(context, day), filled: true, children: [ - for (final item in noDateTasks) + for (final item in groups[day]!) _AgendaRow( item: item, onTap: (context, [globalPosition]) => @@ -147,6 +167,22 @@ class _ScheduleAgendaViewState extends State { } } +class _AgendaLoadMoreRow extends StatelessWidget { + const _AgendaLoadMoreRow({required this.title, required this.onTap}); + + final String title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return BusyMaxActionRow( + title: title, + leading: const Icon(YaruIcons.plus, size: BusyMaxSizes.iconSm), + onTap: onTap, + ); + } +} + class _AgendaRow extends StatelessWidget { const _AgendaRow({ required this.item, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index da4333a..2b6cc69 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -54,6 +54,8 @@ 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; @@ -70,6 +72,8 @@ class _ScheduleWorkspaceState extends ConsumerState { var _latestCanShowSidebar = false; var _latestItems = const []; var _agendaLoadedDays = _agendaInitialDays; + var _agendaOverdueTaskLimit = _agendaInitialTaskBucketLimit; + var _agendaNoDateTaskLimit = _agendaInitialTaskBucketLimit; ScheduleViewMode? _lastSettingsMode; _HeaderBarStateSnapshot? _lastHeaderBarState; @@ -145,7 +149,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ) .toList(); - return FutureBuilder>( + return FutureBuilder<_ScheduleItemsResult>( future: _scheduleItems( repository: ref.watch(scheduleRepositoryProvider), range: range, @@ -164,7 +168,7 @@ class _ScheduleWorkspaceState extends ConsumerState { taskListsLoading || itemsLoading; final scopedItems = ScheduleProjection.filterByScope( - snapshot.data ?? const [], + snapshot.data?.items ?? const [], _scope, ); final items = @@ -262,6 +266,22 @@ class _ScheduleWorkspaceState extends ConsumerState { !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( @@ -527,7 +547,7 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } - Future> _scheduleItems({ + Future<_ScheduleItemsResult> _scheduleItems({ required ScheduleRepository repository, required ScheduleRange range, required bool searchHasQuery, @@ -547,31 +567,42 @@ class _ScheduleWorkspaceState extends ConsumerState { includeCalendarEvents: _scope != ScheduleScope.tasks, includeTasks: _scope != ScheduleScope.events, showCompletedTasks: true, - showNoDateTasks: true, + showNoDateTasks: searchHasQuery || _mode != ScheduleViewMode.agenda, ), ); if (searchHasQuery || _mode != ScheduleViewMode.agenda) { - return currentItems; + return _ScheduleItemsResult(items: await currentItems); } - final overdueTasks = repository.listItems( - range: _allOverdueTasksRange(range), + final overdueTasks = repository.listOverdueTasks( + before: range.start, + limit: _agendaOverdueTaskLimit, filters: ScheduleFilters( accountIds: accountIds, taskListIds: taskListIds, taskListFilterActive: true, - includeCalendarEvents: false, includeTasks: _scope != ScheduleScope.events, showCompletedTasks: false, - showNoDateTasks: false, ), ); - final results = await Future.wait([currentItems, overdueTasks]); - return [...results[0], ...results[1]]; - } - - ScheduleRange _allOverdueTasksRange(ScheduleRange displayRange) { - return ScheduleRange(start: DateTime(1), end: displayRange.start); + 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( @@ -832,8 +863,28 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } + 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( @@ -1277,6 +1328,18 @@ class _HeaderBarStateSnapshot { ); } +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, @@ -1298,6 +1361,10 @@ class _ScheduleBody extends StatelessWidget { 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.onTaskCompletionChanged, }); @@ -1321,6 +1388,10 @@ class _ScheduleBody extends StatelessWidget { final VoidCallback onPrevious; final VoidCallback onNext; final VoidCallback? onAgendaLoadMore; + final bool hasMoreAgendaOverdueTasks; + final bool hasMoreAgendaNoDateTasks; + final VoidCallback? onAgendaLoadMoreOverdue; + final VoidCallback? onAgendaLoadMoreNoDate; final ScheduleItemSelectionCallback onItemSelected; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -1387,7 +1458,11 @@ class _ScheduleBody extends StatelessWidget { ScheduleViewMode.agenda => ScheduleAgendaView( range: range, items: items, + hasMoreOverdueTasks: hasMoreAgendaOverdueTasks, + hasMoreNoDateTasks: hasMoreAgendaNoDateTasks, onLoadMore: onAgendaLoadMore, + onLoadMoreOverdue: onAgendaLoadMoreOverdue, + onLoadMoreNoDate: onAgendaLoadMoreNoDate, onItemSelected: onItemSelected, onTaskCompletionChanged: onTaskCompletionChanged, ), diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 67ec42d..3578266 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -8,6 +8,7 @@ import '../db/app_database.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 +21,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 +33,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 +58,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 +99,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, @@ -182,44 +225,211 @@ 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, - categories: _stringListFromJson(task.categoriesJson), - 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), + 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( diff --git a/test/features/schedule/application/compact_agenda_sections_test.dart b/test/features/schedule/application/compact_agenda_sections_test.dart index 43b9ffd..36d8033 100644 --- a/test/features/schedule/application/compact_agenda_sections_test.dart +++ b/test/features/schedule/application/compact_agenda_sections_test.dart @@ -74,6 +74,18 @@ void main() { ); }); + 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, @@ -90,6 +102,44 @@ void main() { 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, @@ -100,14 +150,37 @@ void main() { 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: true')); - expect(source, contains('ScheduleRange(start: DateTime(1), end: today)')); - expect(source, contains('includeCalendarEvents: false')); + 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')); }); } diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 89cca5f..c85344e 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -1,3 +1,5 @@ +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'; @@ -51,10 +53,24 @@ void main() { testWidgets('empty state shows positive empty message', (tester) async { await tester.pumpWidget(_testPanel(data: _data(today))); - expect(find.text('Clear for the next 7 days'), findsOneWidget); + 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')); + }); + testWidgets('no-date tasks render in a No date section', (tester) async { await tester.pumpWidget( _testPanel(data: _data(today, items: [_task('Plan someday')])), @@ -72,6 +88,73 @@ void main() { 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 { @@ -264,6 +347,8 @@ Widget _testPanel({ AsyncValue _data( DateTime today, { List items = const [], + bool hasMoreOverdueTasks = false, + bool hasMoreNoDateTasks = false, bool hasSignedInAccounts = true, bool hasSources = true, }) { @@ -272,9 +357,11 @@ AsyncValue _data( today: today, range: ScheduleRange( start: today, - end: today.add(const Duration(days: 7)), + end: today.add(const Duration(days: 30)), ), items: items, + hasMoreOverdueTasks: hasMoreOverdueTasks, + hasMoreNoDateTasks: hasMoreNoDateTasks, hasSignedInAccounts: hasSignedInAccounts, hasSources: hasSources, generatedAt: today, diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 4c1920d..9bd01fe 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -722,6 +722,14 @@ void main() { 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', ( @@ -751,9 +759,7 @@ void main() { expect(find.text('New task'), findsNothing); }); - testWidgets('agenda view asks for more items near the bottom', ( - tester, - ) async { + testWidgets('agenda view asks for more items at the bottom', (tester) async { final selectedDate = DateTime(2026, 1, 15); var loadMoreCount = 0; @@ -797,6 +803,66 @@ void main() { 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', @@ -1342,10 +1408,23 @@ void main() { 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( @@ -1361,10 +1440,10 @@ void main() { source, isNot(contains('ScheduleViewMode.agenda => ScheduleRange.week')), ); - expect(source, contains('ScheduleRange _allOverdueTasksRange')); - expect(source, contains('start: DateTime(1)')); - expect(source, contains('end: displayRange.start')); + 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', () { @@ -1405,19 +1484,30 @@ void main() { expect(nativeRunner, contains('setNavigationVisible')); }); - test('agenda queries overdue tasks separately from current events', () { + 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> _scheduleItems')); + expect(source, contains('Future<_ScheduleItemsResult> _scheduleItems')); expect(source, contains('final currentItems = repository.listItems')); - expect(source, contains('final overdueTasks = repository.listItems')); - expect(source, contains('range: _allOverdueTasksRange(range)')); - expect(source, contains('includeCalendarEvents: false')); + 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('showNoDateTasks: false')); - expect(source, contains('Future.wait([currentItems, overdueTasks])')); + 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( diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 92ae720..8bad2de 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -251,6 +251,103 @@ void main() { 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 { @@ -307,4 +404,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'; From 7760527c04e2a8d3715ee007785005b8d8d6eef9 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 03:23:26 -0700 Subject: [PATCH 35/53] Refactor task creation to utilize extended input fields and streamline data handling --- .../presentation/schedule_workspace.dart | 9 +- .../features/tasks/data/tasks_repository.dart | 48 +-- .../tasks/presentation/new_task_dialog.dart | 310 ++++++++---------- .../presentation/task_details_draft.dart | 34 ++ .../presentation/task_details_editor.dart | 123 +++++-- .../tasks/presentation/tasks_workspace.dart | 5 +- test/app/native_ui_audit_test.dart | 2 +- .../tasks/data/tasks_repository_test.dart | 51 +++ 8 files changed, 351 insertions(+), 231 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 2b6cc69..bc403c8 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1106,14 +1106,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } await ref .read(tasksRepositoryForAccountProvider(draft.accountId)) - .createTask( - draft.taskListId, - TaskCreateInput( - title: draft.title, - dueUtc: draft.dueUtc, - categories: draft.categories, - ), - ); + .createTask(draft.taskListId, draft.input); } Map> _categorySuggestionsByAccount() { diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index e8a3946..2c29f80 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -174,6 +174,7 @@ class TaskCreateInput { this.status, this.dueUtc, this.categories = const [], + this.fields = const {}, this.parentTaskId, this.previousSiblingTaskId, }); @@ -183,8 +184,24 @@ class TaskCreateInput { 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 { @@ -344,45 +361,32 @@ class TasksRepository { Future createTask(String taskListId, TaskCreateInput input) async { final now = _now(); final localId = 'local-task-${_uuid.v4()}'; - final due = normalizeGoogleDueDateValue(input.dueUtc); - final categories = [ - for (final category in input.categories) - if (category.trim().isNotEmpty) category.trim(), - ]; + 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), - categoriesJson: categories.isEmpty - ? const Value.absent() - : Value(_jsonOrNull(categories)), + 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!), - if (categories.isNotEmpty) 'categories': categories, - }, + 'body': _remoteTaskFields(fields), if (input.parentTaskId != null) 'parent': input.parentTaskId, if (input.previousSiblingTaskId != null) 'previous': input.previousSiblingTaskId, @@ -390,6 +394,10 @@ class TasksRepository { createdAtUtc: now, ); }); + await NotificationScheduleService( + database: _database, + nowUtc: _nowUtc, + ).rebuildUpcomingTaskNotifications(_accountId); _onMutationQueued?.call(); } diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index ab1897f..2008205 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -1,31 +1,32 @@ 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, - this.categories = const [], + required this.input, }); - final String title; final String accountId; final String taskListId; - final DateTime? dueUtc; - final List categories; + final TaskCreateInput input; + + String get title => input.title; + DateTime? get dueUtc => input.dueUtc; + List get categories => input.categories; } Future showBusyMaxNewTaskDialog( @@ -40,8 +41,8 @@ Future showBusyMaxNewTaskDialog( return showBusyMaxModalEditorDialog( context, headerBarService: headerBarService, - maxWidth: 460, - maxHeight: 560, + maxWidth: 640, + maxHeight: 760, builder: (dialogContext) => UncontrolledProviderScope( container: ProviderScope.containerOf(context), child: _NewTaskDialog( @@ -72,12 +73,9 @@ class _NewTaskDialog extends ConsumerStatefulWidget { } class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { - final _categoryController = TextEditingController(); - var _title = ''; String? _accountId; String? _taskListId; - var _addingCategory = false; - var _categories = const []; + TaskDetailsDraft? _draftSnapshot; @override void initState() { @@ -89,26 +87,19 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { _taskListId = widget.initialListId; } - @override - void dispose() { - _categoryController.dispose(); - super.dispose(); - } - @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 tasksRepository = ref.watch( tasksRepositoryForAccountProvider(accountId), ); - final account = _accountForId(accountId); - final capabilities = capabilitiesForProvider( - account?.provider ?? TaskProvider.google, - ); return StreamBuilder>( stream: tasksRepository.watchCategorySuggestions(), @@ -118,126 +109,48 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { 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; + final effectiveListId = _effectiveListId(taskLists); + final editorTask = _newTaskEntity( + accountId: accountId, + taskListId: effectiveListId ?? '', + initialDueUtc: widget.initialDueUtc, + ); + final initialDraft = _initialDraftFor( + editorTask, + effectiveListId, + localTimeZone, + ); - return BusyMaxModalEditorScaffold( - title: l10n.newTask, - cancelLabel: l10n.cancel, - saveLabel: l10n.create, + 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, + onAccountSelected: _selectAccount, + allowTaskListSelection: true, + showAdvancedActions: false, + showDeleteAction: false, + confirmTaskSwitch: false, + categorySuggestions: categorySuggestions, + canSaveDraft: (draft) => draft.taskListId.isNotEmpty, + onDraftChanged: (draft) { + _draftSnapshot = draft; + }, + onRefresh: () {}, + onSave: (draft, _) => + _submit(draft, capabilities, localTimeZone: localTimeZone), + onCreateSubtask: (_) {}, + onMoveToTop: () {}, + onDelete: () async {}, onCancel: () => Navigator.of(context).pop(), - onSave: canCreate ? () => _submit(effectiveListId) : null, - contentMaxWidth: 460, - children: [ - ValidatedFormField( - autofocus: true, - labelText: l10n.title, - onChanged: (value) { - setState(() { - _title = value; - }); - }, - 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 (!capabilitiesForProvider( - _accountForId(value)?.provider ?? - TaskProvider.google, - ).supportsCategories) { - _categories = const []; - _addingCategory = false; - _categoryController.clear(); - } - }); - }, - ), - 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!), - ), - ], - ), - if (capabilities.supportsCategories) - BusyMaxGroupedList( - title: l10n.organizationSection, - children: [ - BusyMaxCategoryEditorRow( - title: l10n.categories, - addLabel: l10n.addCategory, - categories: _categories, - suggestions: categorySuggestions, - adding: _addingCategory, - controller: _categoryController, - inputKey: const Key('new-task-category-input'), - onAddPressed: () { - setState(() { - _addingCategory = true; - }); - }, - onSubmitted: _addCategory, - onCancelAdding: () { - _categoryController.clear(); - setState(() { - _addingCategory = false; - }); - }, - onDeleted: _removeCategory, - ), - ], - ), - const SizedBox(height: BusyMaxSpacing.lg), - ], ); }, ); @@ -245,14 +158,56 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { ); } - String _accountLabel(String accountId) { - return widget.accounts - .firstWhere((account) => account.id == accountId) - .displayLabel; + 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; } - String _listLabel(List taskLists, String taskListId) { - return taskLists.firstWhere((list) => list.id == taskListId).title; + 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) { @@ -264,40 +219,53 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { return null; } - void _addCategory(String value) { - final category = value.trim(); - if (category.isEmpty || _categories.contains(category)) { - return; + String _accountLabel(String accountId) { + return _accountForId(accountId)?.displayLabel ?? accountId; + } + + String _accountEditorLabel( + BuildContext context, + AccountEntity? account, + TaskProvider provider, + ) { + final label = account?.displayLabel.trim(); + if (label != null && label.isNotEmpty) { + return label; } - _categoryController.clear(); - setState(() { - _addingCategory = false; - _categories = [..._categories, category]; - }); + return provider.displayName; } - void _removeCategory(String category) { + void _selectAccount(String value) { + if (value == _accountId) { + return; + } + final provider = _accountForId(value)?.provider ?? TaskProvider.google; + final capabilities = capabilitiesForProvider(provider); setState(() { - _categories = [ - for (final value in _categories) - if (value != category) value, - ]; + _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( NewTaskDraft( - title: title, accountId: accountId, - taskListId: effectiveListId, - dueUtc: widget.initialDueUtc, - categories: _categories, + 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 72bcf63..6000562 100644 --- a/lib/src/features/tasks/presentation/task_details_draft.dart +++ b/lib/src/features/tasks/presentation/task_details_draft.dart @@ -193,6 +193,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, diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 1bc7182..358f238 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -32,7 +32,20 @@ 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.onAccountSelected, + this.allowTaskListSelection, + this.showAdvancedActions = true, + this.showDeleteAction = true, + this.confirmTaskSwitch = true, + this.canSaveDraft, }); final TaskEntity task; @@ -53,7 +66,20 @@ 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 ValueChanged? onAccountSelected; + final bool? allowTaskListSelection; + final bool showAdvancedActions; + final bool showDeleteAction; + final bool confirmTaskSwitch; + final bool Function(TaskDetailsDraft draft)? canSaveDraft; @override State createState() => _TaskDetailsEditorState(); @@ -87,7 +113,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); @@ -114,7 +140,11 @@ 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 = [ @@ -129,9 +159,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, @@ -165,6 +195,11 @@ class _TaskDetailsEditorState extends State { ), ], ), + if (_hasAccountSelector) + BusyMaxGroupedList( + filled: true, + children: [_accountRow()], + ), BusyMaxGroupedList( filled: true, children: [_listRow(draft, listValue)], @@ -257,7 +292,8 @@ class _TaskDetailsEditorState extends State { ), ], ), - if (widget.capabilities.supportsTaskHierarchy) + if (widget.showAdvancedActions && + widget.capabilities.supportsTaskHierarchy) BusyMaxGroupedList( title: l10n.advancedSection, filled: true, @@ -274,27 +310,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), ], ), @@ -306,6 +344,28 @@ 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!; + return BusyMaxComboRow( + title: l10n.account, + leading: const Icon(YaruIcons.user), + values: widget.accountIds, + selected: widget.selectedAccountId!, + labelFor: labelFor, + selectedBuilder: (context, value) => + _taskEditorSelectedValue(context, labelFor(value)), + onSelected: widget.onAccountSelected!, + ); + } + Widget _listRow(TaskDetailsDraft draft, String listValue) { final l10n = context.l10n; if (widget.taskLists.isEmpty) { @@ -316,13 +376,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( @@ -610,7 +675,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; @@ -624,6 +691,7 @@ class _TaskDetailsEditorState extends State { setState(() { _draft = draft; }); + widget.onDraftChanged?.call(draft); widget.onDirtyChanged?.call(_hasDraftChanges(draft)); } @@ -801,7 +869,8 @@ class _TaskDetailsHeader extends StatelessWidget { } } -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/tasks_workspace.dart b/lib/src/features/tasks/presentation/tasks_workspace.dart index 640fde1..623fcbb 100644 --- a/lib/src/features/tasks/presentation/tasks_workspace.dart +++ b/lib/src/features/tasks/presentation/tasks_workspace.dart @@ -441,10 +441,7 @@ Future _createTaskFromWorkspace( } await ref .read(tasksRepositoryForAccountProvider(draft.accountId)) - .createTask( - draft.taskListId, - TaskCreateInput(title: draft.title, categories: draft.categories), - ); + .createTask(draft.taskListId, draft.input); } Future _refreshList(BuildContext context, SyncEngine syncEngine) async { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index d0d238e..1aa9000 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -107,7 +107,7 @@ void main() { expect(settings, contains('setBackVisible(true)')); expect(settings, contains('setSidebarVisible(true)')); expect(newTaskDialog, contains('showBusyMaxModalEditorDialog')); - expect(newTaskDialog, contains('BusyMaxModalEditorScaffold')); + expect(newTaskDialog, contains('TaskDetailsEditor')); expect(newTaskDialog, isNot(contains('BusyMaxDialogShell'))); expect(scheduleAgenda, contains('BusyMaxGroupedList')); diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index 36a376a..5ad9660 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( From 2b006f9cb42a9337afac62d4bd4a0aa78fa87156 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 03:27:37 -0700 Subject: [PATCH 36/53] Add secondary label support for account selection in task editor --- .../tasks/presentation/new_task_dialog.dart | 5 ++ .../presentation/task_details_editor.dart | 55 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 2008205..3c3fd5a 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -134,6 +134,7 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { accountIds: [for (final account in widget.accounts) account.id], selectedAccountId: accountId, accountLabelFor: _accountLabel, + accountSecondaryLabelFor: _accountSecondaryLabel, onAccountSelected: _selectAccount, allowTaskListSelection: true, showAdvancedActions: false, @@ -223,6 +224,10 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { return _accountForId(accountId)?.displayLabel ?? accountId; } + String? _accountSecondaryLabel(String accountId) { + return _accountForId(accountId)?.secondaryLabel; + } + String _accountEditorLabel( BuildContext context, AccountEntity? account, diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 358f238..c6a9f06 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -40,6 +40,7 @@ class TaskDetailsEditor extends StatefulWidget { this.accountIds = const [], this.selectedAccountId, this.accountLabelFor, + this.accountSecondaryLabelFor, this.onAccountSelected, this.allowTaskListSelection, this.showAdvancedActions = true, @@ -74,6 +75,7 @@ class TaskDetailsEditor extends StatefulWidget { 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; @@ -354,14 +356,21 @@ class _TaskDetailsEditorState extends State { 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, - selectedBuilder: (context, value) => - _taskEditorSelectedValue(context, labelFor(value)), + 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!, ); } @@ -827,6 +836,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, From 79be8bfd84c54d6f5e0fca79576fb27b0a0ebd7e Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 04:14:53 -0700 Subject: [PATCH 37/53] Enhance notification scheduling and reminder handling across the application --- lib/src/app/app_bootstrap.dart | 12 +- lib/src/app/app_settings.dart | 2 +- lib/src/app/busymax_design.dart | 208 ++++++++++++++---- lib/src/db/app_database.dart | 7 + .../calendar/data/calendar_repository.dart | 14 +- .../notifications/notification_scheduler.dart | 57 +++++ .../schedule_item_details_popover.dart | 27 +++ .../features/sync/calendar_sync_engine.dart | 5 + .../features/tasks/data/tasks_repository.dart | 31 ++- .../desktop_date_time_fields.dart | 47 ++-- lib/src/schedule/schedule_item.dart | 4 + lib/src/schedule/schedule_repository.dart | 39 ++++ linux/CMakeLists.txt | 1 + linux/busymax.desktop | 2 +- linux/runner/my_application.cc | 6 +- test/app/theme_localization_test.dart | 9 + .../presentation/event_editor_test.dart | 18 +- .../desktop_notification_service_test.dart | 31 +++ .../notification_scheduler_test.dart | 147 +++++++++++++ .../presentation/schedule_views_test.dart | 91 ++++++++ .../schedule/schedule_search_test.dart | 12 + .../tasks/data/tasks_repository_test.dart | 40 ++++ .../presentation/task_details_pane_test.dart | 11 +- test/platform/busymax_tray_service_test.dart | 26 +++ 24 files changed, 755 insertions(+), 92 deletions(-) create mode 100644 test/features/notifications/notification_scheduler_test.dart diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 6302200..7cf593a 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -282,6 +282,8 @@ final calendarSyncEngineForAccountFactoryProvider = onConflictBlocked: ref .read(desktopNotificationServiceProvider) .notifyConflict, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }; }); @@ -342,7 +344,11 @@ authSessionControllerProvider = }); final calendarRepositoryProvider = Provider((ref) { - return CalendarRepository(database: ref.watch(databaseProvider)); + return CalendarRepository( + database: ref.watch(databaseProvider), + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), + ); }); final scheduleRepositoryProvider = Provider((ref) { @@ -410,6 +416,8 @@ final tasksRepositoryProvider = Provider((ref) { accountId: accountId, apiClient: ref.watch(googleTasksApiClientProvider), onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }); @@ -427,6 +435,8 @@ final tasksRepositoryForAccountProvider = onMutationQueued: ref .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, + onNotificationScheduleChanged: () => + ref.read(notificationSchedulerProvider).checkNow(), ); }); diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 186e0f7..444fb72 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -68,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', diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 541098e..b976a4c 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -976,15 +976,11 @@ class BusyMaxCategoryEditorRow extends StatelessWidget { _BusyMaxCategoryInputChip( controller: controller, hintText: addLabel, + suggestions: visibleSuggestions, inputKey: inputKey, onSubmitted: onSubmitted, onCancel: onCancelAdding, ), - for (final suggestion in visibleSuggestions) - _BusyMaxCategorySuggestionChip( - label: suggestion, - onPressed: () => onSubmitted(suggestion), - ), ] else _BusyMaxAddCategoryChip(label: addLabel, onPressed: onAddPressed), ], @@ -1096,43 +1092,11 @@ class _BusyMaxAddCategoryChip extends StatelessWidget { } } -class _BusyMaxCategorySuggestionChip extends StatelessWidget { - const _BusyMaxCategorySuggestionChip({ - required this.label, - required this.onPressed, - }); - - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ActionChip( - avatar: Icon( - YaruIcons.plus, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - label: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - labelStyle: Theme.of(context).textTheme.labelLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - side: BorderSide(color: colorScheme.outlineVariant), - backgroundColor: Colors.transparent, - onPressed: onPressed, - ); - } -} - -class _BusyMaxCategoryInputChip extends StatelessWidget { +class _BusyMaxCategoryInputChip extends StatefulWidget { const _BusyMaxCategoryInputChip({ required this.controller, required this.hintText, + required this.suggestions, this.inputKey, required this.onSubmitted, required this.onCancel, @@ -1140,10 +1104,31 @@ class _BusyMaxCategoryInputChip extends StatelessWidget { 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; @@ -1164,17 +1149,37 @@ class _BusyMaxCategoryInputChip extends StatelessWidget { child: Row( children: [ Expanded( - child: TextField( - key: inputKey, - controller: controller, - autofocus: true, - decoration: InputDecoration.collapsed(hintText: hintText), - textInputAction: TextInputAction.done, - onSubmitted: onSubmitted, + child: RawAutocomplete( + textEditingController: widget.controller, + focusNode: _focusNode, + displayStringForOption: (option) => option, + optionsViewOpenDirection: OptionsViewOpenDirection.up, + optionsBuilder: _categoryOptionsFor, + onSelected: widget.onSubmitted, + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextField( + key: widget.inputKey, + controller: controller, + focusNode: focusNode, + autofocus: true, + decoration: InputDecoration.collapsed( + hintText: widget.hintText, + ), + textInputAction: TextInputAction.done, + onSubmitted: _submitTypedCategory, + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return _BusyMaxCategoryAutocompleteOptions( + options: options.toList(growable: false), + onSelected: onSelected, + ); + }, ), ), InkResponse( - onTap: () => onSubmitted(controller.text), + onTap: () => _submitTypedCategory(widget.controller.text), radius: BusyMaxSizes.iconMd, child: Icon( YaruIcons.checkmark, @@ -1184,7 +1189,7 @@ class _BusyMaxCategoryInputChip extends StatelessWidget { ), const SizedBox(width: BusyMaxSpacing.xs), InkResponse( - onTap: onCancel, + onTap: widget.onCancel, radius: BusyMaxSizes.iconMd, child: Icon( YaruIcons.window_close, @@ -1198,6 +1203,113 @@ class _BusyMaxCategoryInputChip extends StatelessWidget { ), ); } + + 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 _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; + const width = 180.0; + const menuAffordanceWidth = 36.0; + const labelWidth = width - BusyMaxSpacing.md * 2 - menuAffordanceWidth; + 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: const 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 SizedBox( + width: width, + child: MenuItemButton( + style: busyMaxDropdownMenuItemStyle(context).copyWith( + fixedSize: const WidgetStatePropertyAll(Size(width, 36)), + ), + onPressed: () => onSelected(option), + child: SizedBox( + width: labelWidth, + child: Text( + option, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } } class BusyMaxCalendarValueRow extends StatelessWidget { 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/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 58af36d..cf86289 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -66,12 +66,17 @@ class CalendarSourceEntity { } class CalendarRepository { - CalendarRepository({required AppDatabase database, DateTime Function()? now}) - : _database = database, - _now = now ?? DateTime.now; + CalendarRepository({ + required AppDatabase database, + DateTime Function()? now, + Future Function()? onNotificationScheduleChanged, + }) : _database = database, + _now = now ?? DateTime.now, + _onNotificationScheduleChanged = onNotificationScheduleChanged; final AppDatabase _database; final DateTime Function() _now; + final Future Function()? _onNotificationScheduleChanged; Stream> watchSourcesForAccounts( List accountIds, @@ -424,6 +429,7 @@ class CalendarRepository { await NotificationScheduleService( database: _database, ).rebuildUpcomingEventNotifications(draft.accountId); + await _onNotificationScheduleChanged?.call(); } Future updateLocalEvent(EventEditorDraft draft) async { @@ -512,6 +518,7 @@ class CalendarRepository { await NotificationScheduleService( database: _database, ).rebuildUpcomingEventNotifications(draft.accountId); + await _onNotificationScheduleChanged?.call(); } Future deleteLocalEvent(String eventId) async { @@ -553,6 +560,7 @@ class CalendarRepository { await NotificationScheduleService( database: _database, ).rebuildUpcomingEventNotifications(existing.accountId); + await _onNotificationScheduleChanged?.call(); return existing.accountId; } diff --git a/lib/src/features/notifications/notification_scheduler.dart b/lib/src/features/notifications/notification_scheduler.dart index 96106ac..0d8897d 100644 --- a/lib/src/features/notifications/notification_scheduler.dart +++ b/lib/src/features/notifications/notification_scheduler.dart @@ -21,18 +21,52 @@ class NotificationScheduler { final Duration _interval; final DateTime Function() _nowUtc; 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( @@ -58,4 +92,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/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index b903945..354c6a9 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -481,6 +481,13 @@ List _eventDetails(BuildContext context, CalendarScheduleItem item) { 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, @@ -505,6 +512,13 @@ 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, @@ -515,6 +529,19 @@ List _taskDetails(BuildContext context, TaskScheduleItem item) { ]; } +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/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/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index 2c29f80..5c9a4a3 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -264,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()); @@ -277,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; @@ -394,10 +397,7 @@ class TasksRepository { createdAtUtc: now, ); }); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -418,10 +418,7 @@ class TasksRepository { baselineRawJson: baseline?.rawJson, createdAtUtc: now, ); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -442,10 +439,7 @@ class TasksRepository { baselineRawJson: baseline?.rawJson, createdAtUtc: now, ); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -470,10 +464,7 @@ class TasksRepository { baselineRawJson: baseline?.rawJson, createdAtUtc: now, ); - await NotificationScheduleService( - database: _database, - nowUtc: _nowUtc, - ).rebuildUpcomingTaskNotifications(_accountId); + await _rebuildTaskNotifications(); _onMutationQueued?.call(); } @@ -538,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, 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 4670488..00152ac 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -320,6 +320,7 @@ class _DesktopTimeValueDialog extends StatefulWidget { class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { late final YaruTimeEntryController? _controller; + final _focusNode = FocusNode(); TimeOfDay? _selected; @override @@ -327,10 +328,32 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { super.initState(); _selected = parseTimeOfDay(widget.time); _controller = _selected == null ? YaruTimeEntryController() : null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); } @override Widget build(BuildContext context) { + final timeEntry = _controller == null + ? YaruTimeEntry( + focusNode: _focusNode, + initialTimeOfDay: _selected, + force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + acceptEmpty: widget.allowEmpty, + clearIconSemanticLabel: widget.label, + onChanged: _setSelectedTime, + ) + : YaruTimeEntry( + controller: _controller, + focusNode: _focusNode, + force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + acceptEmpty: widget.allowEmpty, + clearIconSemanticLabel: widget.label, + onChanged: _setSelectedTime, + ); return BusyMaxDialogShell( title: widget.label, maxWidth: 360, @@ -351,25 +374,15 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], - children: [ - _withoutInternalDateTimeEntryLabel( - context, - YaruTimeEntry( - controller: _controller, - initialTimeOfDay: _controller == null ? _selected : null, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: widget.allowEmpty, - clearIconSemanticLabel: widget.label, - onChanged: (time) { - setState(() { - _selected = time; - }); - }, - ), - ), - ], + children: [_withoutInternalDateTimeEntryLabel(context, timeEntry)], ); } + + void _setSelectedTime(TimeOfDay? time) { + setState(() { + _selected = time; + }); + } } class _DesktopTimeFieldState extends State { diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 9279e0c..37a4728 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -35,6 +35,7 @@ class CalendarScheduleItem implements ScheduleItem { this.descriptionHtml, this.colorHex, this.categories = const [], + this.reminderMinutesBeforeStart = const [], this.sourceName, this.accountDisplayName, this.accountEmail, @@ -64,6 +65,7 @@ class CalendarScheduleItem implements ScheduleItem { final String? colorHex; @override final List categories; + final List reminderMinutesBeforeStart; @override final String? sourceName; @override @@ -88,6 +90,7 @@ class TaskScheduleItem implements ScheduleItem { this.end, this.notes, this.categories = const [], + this.reminder, this.sourceName, this.accountDisplayName, this.accountEmail, @@ -113,6 +116,7 @@ class TaskScheduleItem implements ScheduleItem { final String? notes; @override final List categories; + final DateTime? reminder; @override final String? sourceName; @override diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 3578266..8c69c6c 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -182,6 +182,10 @@ class ScheduleRepository { descriptionContentType: descriptionBody.contentType, descriptionHtml: descriptionBody.html, categories: _stringListFromJson(event.categoriesJson), + reminderMinutesBeforeStart: _eventReminderMinutes( + provider, + event.remindersJson, + ), colorHex: event.colorHex ?? calendarSourceBackgroundColorHex( @@ -355,6 +359,9 @@ class ScheduleRepository { end: _taskEnd(task, provider), notes: task.notes ?? task.bodyContent, categories: _stringListFromJson(task.categoriesJson), + reminder: task.microsoftIsReminderOn == true + ? DateTime.tryParse(task.microsoftReminderDateTime ?? '') + : null, sourceName: list?.title, accountDisplayName: accountDisplayNames[task.accountId], accountEmail: accountEmails[task.accountId], @@ -569,3 +576,35 @@ List _stringListFromJson(String? value) { } return const []; } + +List _eventReminderMinutes(BusyProvider provider, String? value) { + 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 => 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 []; + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 0251624..8bf1e0f 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -96,6 +96,7 @@ install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/busymax.desktop" DESTINATION "${CMAKE_INSTALL_PREFIX}/share/applications" + RENAME "io.busystack.busymax.desktop" COMPONENT Runtime) install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/../assets/branding/busymax-logo.png" diff --git a/linux/busymax.desktop b/linux/busymax.desktop index 7d10f05..2af431e 100644 --- a/linux/busymax.desktop +++ b/linux/busymax.desktop @@ -7,4 +7,4 @@ Icon=io.busystack.busymax Terminal=false Categories=Office;Calendar;ProjectManagement;Utility; StartupNotify=true -StartupWMClass=io.busystack.busymax +StartupWMClass=BusyMax diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 528da93..fe908a0 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2694,9 +2694,11 @@ static void my_application_activate(GApplication* application) { } G_GNUC_BEGIN_IGNORE_DEPRECATIONS gtk_window_set_wmclass( - window, APPLICATION_ID, APPLICATION_ID); + window, kApplicationDisplayName, kApplicationDisplayName); 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); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 98f8335..c51b2d3 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'; @@ -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 { @@ -862,10 +867,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/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 9dc3110..09d7a81 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -153,6 +153,12 @@ void main() { final entry = tester.widget(find.byType(YaruTimeEntry)); expect(entry.initialTimeOfDay, const TimeOfDay(hour: 9, minute: 0)); expect(entry.acceptEmpty, isFalse); + expect( + tester + .widgetList(find.byType(EditableText)) + .any((entry) => entry.controller.text.contains('09:00')), + isTrue, + ); }); test('event draft requires end after start', () { @@ -454,8 +460,16 @@ void main() { await tester.tap(find.text('Add category')); await tester.pumpAndSettle(); - await tester.ensureVisible(find.text('Work')); - await tester.tap(find.text('Work')); + await tester.enterText(find.byKey(const Key('event-category-input')), 'wo'); + await tester.pumpAndSettle(); + await tester.tap( + find + .ancestor( + of: find.text('Work').last, + matching: find.byType(MenuItemButton), + ) + .last, + ); await tester.pumpAndSettle(); await tester.tap(_headerButtonFinder('Save')); diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 48f5dcf..6099d90 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -59,6 +59,37 @@ 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('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.', + ); + }); } class _FakeNotificationBackend implements DesktopNotificationBackend { diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart new file mode 100644 index 0000000..37aeead --- /dev/null +++ b/test/features/notifications/notification_scheduler_test.dart @@ -0,0 +1,147 @@ +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, 'Task reminder'); + 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, 'Task reminder'); + }); +} + +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 [], + }) async { + notifications.add(_NotificationRecord(summary, body)); + } + + @override + Future close() async {} +} + +class _NotificationRecord { + const _NotificationRecord(this.summary, this.body); + + final String summary; + final String body; +} diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 9bd01fe..0a7fc64 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -445,6 +445,97 @@ void main() { 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 { diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 8bad2de..fe2ea83 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,8 @@ 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']); }, ); @@ -192,6 +199,9 @@ void main() { 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, @@ -212,6 +222,8 @@ void main() { 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 task with date-only due appears as all-day', () async { diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index 5ad9660..dc61506 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -147,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( @@ -254,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_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 9b9110e..4f0de0f 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -592,7 +592,16 @@ void main() { await tester.tap(find.text('Add category')); await tester.pumpAndSettle(); - await tester.tap(find.text('Work')); + await tester.enterText(find.byKey(const Key('task-category-input')), 'wo'); + await tester.pumpAndSettle(); + await tester.tap( + find + .ancestor( + of: find.text('Work').last, + matching: find.byType(MenuItemButton), + ) + .last, + ); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index 5a88822..087cf95 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -43,6 +43,32 @@ void main() { expect(busyMaxApplicationId, 'io.busystack.busymax'); }); + test('Linux desktop identity matches the displayed BusyMax window', () { + final desktop = File('linux/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=BusyMax')); + expect(cmake, contains('RENAME "io.busystack.busymax.desktop"')); + expect( + runner, + contains( + 'gtk_window_set_wmclass(\n' + ' window, kApplicationDisplayName, kApplicationDisplayName);', + ), + ); + 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', From 2e2ace6b4627dd01015ff4697cc35b68ca43cd7a Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 04:21:22 -0700 Subject: [PATCH 38/53] Refactor notification scheduling to use a dedicated service method and enhance date parsing for event reminders --- .../calendar/data/calendar_repository.dart | 25 ++++-- .../notification_schedule_service.dart | 28 +++++- .../notification_schedule_service_test.dart | 85 ++++++++++++++++++- .../notification_scheduler_test.dart | 4 +- .../calendar_pending_ops_replayer_test.dart | 3 + 5 files changed, 132 insertions(+), 13 deletions(-) diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index cf86289..47f504d 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -426,9 +426,9 @@ class CalendarRepository { ), ); }); - await NotificationScheduleService( - database: _database, - ).rebuildUpcomingEventNotifications(draft.accountId); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); await _onNotificationScheduleChanged?.call(); } @@ -515,9 +515,9 @@ class CalendarRepository { ), ); }); - await NotificationScheduleService( - database: _database, - ).rebuildUpcomingEventNotifications(draft.accountId); + await _notificationScheduleService().rebuildUpcomingEventNotifications( + draft.accountId, + ); await _onNotificationScheduleChanged?.call(); } @@ -557,13 +557,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, diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index f84e844..2774e48 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -129,7 +129,7 @@ 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) { @@ -166,3 +166,29 @@ DateTime? _parseDate(String? value) { } 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/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index 2867a73..661366d 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'; @@ -75,6 +76,85 @@ 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('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, @@ -137,6 +217,8 @@ Future _upsertEvent( required String accountId, required TaskProvider provider, required Object remindersJson, + String startDateTime = '2026-06-08T09:00:00.000Z', + String? startTimeZone, }) async { final repository = CalendarRepository(database: database); await repository.upsertSource( @@ -154,7 +236,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'}, diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart index 37aeead..a60e195 100644 --- a/test/features/notifications/notification_scheduler_test.dart +++ b/test/features/notifications/notification_scheduler_test.dart @@ -67,7 +67,7 @@ void main() { await _waitUntil(() => backend.notifications.isNotEmpty); - expect(backend.notifications.single.summary, 'Task reminder'); + expect(backend.notifications.single.summary, 'File report'); final rows = await database.select(database.notificationSchedule).get(); expect(rows.single.sentAtUtc, isNotNull); }); @@ -106,7 +106,7 @@ void main() { await _waitUntil(() => backend.notifications.isNotEmpty); - expect(backend.notifications.single.summary, 'Task reminder'); + expect(backend.notifications.single.summary, 'Future report'); }); } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 3f51308..2524395 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -328,18 +328,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 { From 671ca35991c59794c053d1e214fbfb6364198592 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 04:44:03 -0700 Subject: [PATCH 39/53] Add time zone support for event scheduling and enhance reminder logic --- .../notification_schedule_service.dart | 6 ++- .../presentation/schedule_workspace.dart | 2 + .../desktop_date_time_fields.dart | 29 ++++-------- lib/src/schedule/schedule_item.dart | 4 ++ lib/src/schedule/schedule_repository.dart | 2 + .../presentation/event_editor_test.dart | 11 ++++- .../notification_schedule_service_test.dart | 40 ++++++++++++++++ .../presentation/schedule_views_test.dart | 22 +++++++++ .../schedule/schedule_search_test.dart | 46 +++++++++++++++++++ .../calendar_pending_ops_replayer_test.dart | 12 ++++- 10 files changed, 151 insertions(+), 23 deletions(-) diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 2774e48..242cedb 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -38,10 +38,12 @@ class NotificationScheduleService { } final reminders = _eventReminderMinutes(event); 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, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index bc403c8..561cb2a 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -960,6 +960,8 @@ 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, 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 00152ac..8837e3d 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -319,7 +319,7 @@ class _DesktopTimeValueDialog extends StatefulWidget { } class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { - late final YaruTimeEntryController? _controller; + late final YaruTimeEntryController _controller; final _focusNode = FocusNode(); TimeOfDay? _selected; @@ -327,7 +327,7 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { void initState() { super.initState(); _selected = parseTimeOfDay(widget.time); - _controller = _selected == null ? YaruTimeEntryController() : null; + _controller = YaruTimeEntryController(timeOfDay: _selected); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _focusNode.requestFocus(); @@ -337,23 +337,14 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { @override Widget build(BuildContext context) { - final timeEntry = _controller == null - ? YaruTimeEntry( - focusNode: _focusNode, - initialTimeOfDay: _selected, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: widget.allowEmpty, - clearIconSemanticLabel: widget.label, - onChanged: _setSelectedTime, - ) - : YaruTimeEntry( - controller: _controller, - focusNode: _focusNode, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: widget.allowEmpty, - clearIconSemanticLabel: widget.label, - onChanged: _setSelectedTime, - ); + final timeEntry = YaruTimeEntry( + controller: _controller, + focusNode: _focusNode, + force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + acceptEmpty: widget.allowEmpty, + clearIconSemanticLabel: widget.label, + onChanged: _setSelectedTime, + ); return BusyMaxDialogShell( title: widget.label, maxWidth: 360, diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 37a4728..2d6bb6c 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -29,6 +29,8 @@ class CalendarScheduleItem implements ScheduleItem { required this.allDay, this.start, this.end, + this.startTimeZone, + this.endTimeZone, this.location, this.description, this.descriptionContentType, @@ -56,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; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 8c69c6c..ab9e209 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -177,6 +177,8 @@ class ScheduleRepository { allDay: event.allDay, start: start, end: end, + startTimeZone: event.startTimeZone, + endTimeZone: event.endTimeZone, location: event.location, description: event.description, descriptionContentType: descriptionBody.contentType, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 09d7a81..28ea0b8 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -151,7 +151,7 @@ void main() { await tester.pumpAndSettle(); final entry = tester.widget(find.byType(YaruTimeEntry)); - expect(entry.initialTimeOfDay, const TimeOfDay(hour: 9, minute: 0)); + expect(entry.controller?.timeOfDay, const TimeOfDay(hour: 9, minute: 0)); expect(entry.acceptEmpty, isFalse); expect( tester @@ -159,6 +159,15 @@ void main() { .any((entry) => entry.controller.text.contains('09:00')), isTrue, ); + + entry.onChanged?.call(null); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + tester.widget(find.byType(YaruTimeEntry)).controller, + isNotNull, + ); }); test('event draft requires end after start', () { diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index 661366d..44ed4f7 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -114,6 +114,46 @@ void main() { ); }); + 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( diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 0a7fc64..6f4891c 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1623,6 +1623,28 @@ void main() { 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', diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index fe2ea83..9c1580f 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -136,6 +136,52 @@ void main() { }, ); + 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('Microsoft task with start and due appears on start day', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 2524395..4734e63 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -144,6 +144,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, @@ -476,6 +479,7 @@ final _later = DateTime.utc(2026, 6, 9); class _FakeCalendarClient implements CloudCalendarClient { final calls = []; final createdMutations = []; + final updatedMutations = []; int _createdCount = 0; GoogleCalendarApiError? deleteError; @@ -521,7 +525,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 From 9f90f3452e9798806675c63acee846ae549f10c5 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 04:53:36 -0700 Subject: [PATCH 40/53] Enhance notification detail settings synchronization and update reminder logic --- lib/src/app/app_settings.dart | 36 +++++++++++++------ .../desktop_notification_service.dart | 11 +++--- test/app/theme_localization_test.dart | 21 +++++++++++ .../desktop_notification_service_test.dart | 19 ++++++++++ 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 444fb72..a12fca7 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -102,6 +102,16 @@ class AppSettings { 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, @@ -133,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: @@ -147,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']), @@ -401,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) { @@ -413,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) { diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index 3bff833..bf5b4ae 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -117,8 +117,7 @@ class DesktopNotificationService { if (!_settings.notifyEventReminders || _isQuietHours()) { return; } - final private = - _settings.notificationDetailLevel == NotificationDetailLevel.private; + final private = _usesPrivateReminderText; await _safeNotify( private ? _strings.eventReminderTitle : redactForLog(title), private @@ -132,8 +131,7 @@ class DesktopNotificationService { if (!_settings.notifyTaskReminders || _isQuietHours()) { return; } - final private = - _settings.notificationDetailLevel == NotificationDetailLevel.private; + final private = _usesPrivateReminderText; await _safeNotify( private ? _strings.taskReminderTitle : redactForLog(title), private @@ -143,6 +141,11 @@ class DesktopNotificationService { ); } + bool get _usesPrivateReminderText { + return !_settings.detailedNotifications && + _settings.notificationDetailLevel == NotificationDetailLevel.private; + } + Future _safeNotify( String summary, String body, diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index c51b2d3..d10a1e4 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -798,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(); diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index 6099d90..a0033d5 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -90,6 +90,25 @@ void main() { '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 { From 92f1983b6d23a17291b1fc2ff42e90e14bf15a32 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 05:35:10 -0700 Subject: [PATCH 41/53] Enhance notification handling by adding support for notification activation and integrating event/task opening from reminders --- lib/src/app/app_bootstrap.dart | 157 +++++++++++++ .../desktop_notification_service.dart | 58 ++++- .../notification_schedule_service.dart | 49 +++- .../notifications/notification_scheduler.dart | 23 +- .../presentation/schedule_workspace.dart | 25 +++ .../desktop_date_time_fields.dart | 211 +++++++++++++++--- lib/src/schedule/schedule_repository.dart | 48 +++- test/app/native_ui_audit_test.dart | 3 +- .../presentation/event_editor_test.dart | 56 ++++- .../desktop_notification_service_test.dart | 51 ++++- .../notification_schedule_service_test.dart | 54 +++++ .../notification_scheduler_test.dart | 51 ++++- .../presentation/schedule_views_test.dart | 1 + .../schedule/schedule_search_test.dart | 51 +++++ .../presentation/task_details_pane_test.dart | 74 ++++-- .../presentation/tasks_workspace_test.dart | 2 + 16 files changed, 834 insertions(+), 80 deletions(-) diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 7cf593a..f3c3c49 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -35,7 +35,9 @@ 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'; @@ -538,12 +540,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/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index bf5b4ae..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,7 +127,11 @@ class DesktopNotificationService { ); } - Future notifyEventReminder(String title, String? body) async { + Future notifyEventReminder( + String title, + String? body, { + Future Function()? onActivated, + }) async { if (!_settings.notifyEventReminders || _isQuietHours()) { return; } @@ -124,10 +142,16 @@ class DesktopNotificationService { ? _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; } @@ -138,6 +162,8 @@ class DesktopNotificationService { ? _strings.detailsHidden : _nonEmpty(redactForLog(body ?? ''), _strings.taskReminderBody), NotificationCategory.device(), + onActivated: onActivated, + transient: false, ); } @@ -149,15 +175,27 @@ class DesktopNotificationService { 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 { @@ -216,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, @@ -239,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) => @@ -255,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) => @@ -274,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) => @@ -293,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', @@ -308,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 242cedb..794b7cd 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -23,6 +23,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,7 +42,10 @@ class NotificationScheduleService { if (start == null) { continue; } - final reminders = _eventReminderMinutes(event); + final reminders = _eventReminderMinutes( + event, + source: sourcesById[event.calendarSourceId], + ); for (final minutes in reminders) { final startUtc = start.toUtc(); final reminderAt = startUtc.subtract(Duration(minutes: minutes)); @@ -134,13 +143,13 @@ DateTime? _eventStart(CalendarEvent event) { 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; @@ -149,6 +158,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 []; @@ -162,6 +174,37 @@ 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; diff --git a/lib/src/features/notifications/notification_scheduler.dart b/lib/src/features/notifications/notification_scheduler.dart index 0d8897d..2910258 100644 --- a/lib/src/features/notifications/notification_scheduler.dart +++ b/lib/src/features/notifications/notification_scheduler.dart @@ -11,15 +11,20 @@ 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; @@ -78,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, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 561cb2a..855b78d 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -25,6 +25,7 @@ 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'; @@ -966,6 +967,10 @@ class _ScheduleWorkspaceState extends ConsumerState { description: item.description, descriptionContentType: item.descriptionContentType, descriptionHtml: item.descriptionHtml, + reminders: _eventRemindersForEdit( + item.provider, + item.reminderMinutesBeforeStart, + ), categories: item.categories, ), sources, @@ -1227,6 +1232,26 @@ class _ScheduleWorkspaceState extends ConsumerState { } } +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, 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 8837e3d..0320e35 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -319,31 +319,47 @@ 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 = YaruTimeEntry( + final timeEntry = _BusyMaxTimeTextEntry( controller: _controller, focusNode: _focusNode, - force24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), - acceptEmpty: widget.allowEmpty, - clearIconSemanticLabel: widget.label, - onChanged: _setSelectedTime, + label: widget.label, + errorText: _invalid + ? MaterialLocalizations.of(context).invalidTimeLabel + : null, + onChanged: _setSelectedTimeText, + onSubmitted: (_) => _submit(), ); return BusyMaxDialogShell( title: widget.label, @@ -354,13 +370,8 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { child: Text(context.l10n.cancel), ), BusyMaxPushButton.filled( - onPressed: widget.allowEmpty || _selected != null - ? () { - 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), ), @@ -369,22 +380,34 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { ); } - void _setSelectedTime(TimeOfDay? time) { + void _setSelectedTimeText(String value) { + final trimmed = value.trim(); + final parsed = parseTimeInput(trimmed); setState(() { - _selected = time; + _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), ); } @@ -393,11 +416,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; } } @@ -405,6 +427,7 @@ class _DesktopTimeFieldState extends State { @override void dispose() { + _controller.dispose(); super.dispose(); } @@ -412,16 +435,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)); + } }, ), ); @@ -439,10 +463,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, @@ -522,7 +623,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/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index ab9e209..13474b3 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -187,6 +187,7 @@ class ScheduleRepository { reminderMinutesBeforeStart: _eventReminderMinutes( provider, event.remindersJson, + source: source, ), colorHex: event.colorHex ?? @@ -579,7 +580,11 @@ List _stringListFromJson(String? value) { return const []; } -List _eventReminderMinutes(BusyProvider provider, String? value) { +List _eventReminderMinutes( + BusyProvider provider, + String? value, { + CalendarSource? source, +}) { if (value == null || value.isEmpty) { return const []; } @@ -594,13 +599,17 @@ List _eventReminderMinutes(BusyProvider provider, String? value) { map['isReminderOn'] == true ? [map['reminderMinutesBeforeStart']] : const [], - TaskProvider.google => switch (map['overrides']) { - final List overrides => [ - for (final item in overrides) - if (item is Map && item['method'] == 'popup') item['minutes'], - ], - _ => 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) @@ -610,3 +619,26 @@ List _eventReminderMinutes(BusyProvider provider, String? value) { 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/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 1aa9000..4dfe552 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -121,7 +121,8 @@ void main() { expect(compactAgenda, isNot(contains('scheduleAgendaRowBackground'))); expect(dateTimeFields, contains('YaruDateTimeEntry')); - expect(dateTimeFields, contains('YaruTimeEntry')); + expect(dateTimeFields, contains('_BusyMaxTimeTextEntry')); + expect(dateTimeFields, contains('parseTimeInput')); expect(dateTimeFields, isNot(contains('showDatePicker'))); expect(dateTimeFields, isNot(contains('showTimePicker'))); }, diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 28ea0b8..d45799a 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -8,7 +8,6 @@ import 'package:busymax/src/task_providers/task_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ubuntu_widgets/ubuntu_widgets.dart'; -import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -150,9 +149,9 @@ void main() { await tester.tap(find.text('Start time')); await tester.pumpAndSettle(); - final entry = tester.widget(find.byType(YaruTimeEntry)); - expect(entry.controller?.timeOfDay, const TimeOfDay(hour: 9, minute: 0)); - expect(entry.acceptEmpty, isFalse); + final fieldFinder = _timeTextEntryFinder(); + final entry = tester.widget(fieldFinder); + expect(entry.controller?.text, '09:00'); expect( tester .widgetList(find.byType(EditableText)) @@ -160,14 +159,49 @@ void main() { isTrue, ); - entry.onChanged?.call(null); + await tester.enterText(fieldFinder, ''); await tester.pump(); expect(tester.takeException(), isNull); - expect( - tester.widget(find.byType(YaruTimeEntry)).controller, - isNotNull, + 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', () { @@ -813,6 +847,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 a0033d5..35fa515 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -73,6 +73,40 @@ void main() { 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( @@ -119,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 @@ -128,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 44ed4f7..0752a71 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -59,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, @@ -259,6 +311,7 @@ Future _upsertEvent( 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( @@ -267,6 +320,7 @@ Future _upsertEvent( provider: provider, providerCalendarId: 'cal-1', summary: 'Calendar', + rawJson: sourceRawJson, ), ); await repository.upsertEvent( diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart index a60e195..19dafab 100644 --- a/test/features/notifications/notification_scheduler_test.dart +++ b/test/features/notifications/notification_scheduler_test.dart @@ -108,6 +108,44 @@ void main() { 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( @@ -131,8 +169,10 @@ 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, actions, onAction)); } @override @@ -140,8 +180,15 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { } class _NotificationRecord { - const _NotificationRecord(this.summary, this.body); + 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/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 6f4891c..62cc5cf 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -1037,6 +1037,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( diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 9c1580f..d7174f5 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -182,6 +182,57 @@ void main() { expect(event.endTimeZone, 'Pacific Standard Time'); }); + 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]); + }, + ); + test('Microsoft task with start and due appears on start day', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 4f0de0f..5c68752 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -736,7 +736,7 @@ void main() { }); 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 @@ -750,10 +750,10 @@ 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); @@ -818,7 +818,7 @@ void main() { await tester.tap(find.text('Time Slot')); await tester.pumpAndSettle(); - expect(find.text('Due time'), findsOneWidget); + expect(find.text('Due time'), findsWidgets); expect(find.text('Start time'), findsOneWidget); await tester.tap(find.text('Save')); @@ -867,7 +867,7 @@ void main() { 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); @@ -876,7 +876,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( @@ -890,18 +890,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 { @@ -1132,6 +1172,12 @@ 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', diff --git a/test/features/tasks/presentation/tasks_workspace_test.dart b/test/features/tasks/presentation/tasks_workspace_test.dart index 78fb166..ebaee63 100644 --- a/test/features/tasks/presentation/tasks_workspace_test.dart +++ b/test/features/tasks/presentation/tasks_workspace_test.dart @@ -1710,5 +1710,7 @@ class _FakeNotificationBackend implements DesktopNotificationBackend { String summary, { String body = '', List hints = const [], + List actions = const [], + DesktopNotificationActionHandler? onAction, }) async {} } From 68b1c64e13aa02a763608cb1ab33bb4012247d33 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 07:01:05 -0700 Subject: [PATCH 42/53] Add local time zone support for calendar events and enhance date/time handling --- lib/src/app/app_bootstrap.dart | 1 + lib/src/app/busymax_design.dart | 218 ++++++++++++------ lib/src/core/time/provider_date_time.dart | 67 ++++++ .../calendar/data/calendar_repository.dart | 57 ++++- .../notification_schedule_service.dart | 8 +- .../presentation/schedule_agenda_view.dart | 15 ++ .../presentation/schedule_item_selection.dart | 3 + .../presentation/schedule_workspace.dart | 78 ++++++- .../presentation/task_details_draft.dart | 109 +++++++-- lib/src/schedule/schedule_repository.dart | 33 ++- test/core/time/provider_date_time_test.dart | 27 +++ .../presentation/event_editor_test.dart | 9 +- .../notification_schedule_service_test.dart | 26 ++- .../presentation/schedule_views_test.dart | 33 +++ .../schedule/schedule_search_test.dart | 90 +++++++- .../calendar_pending_ops_replayer_test.dart | 94 ++++++++ .../presentation/task_details_draft_test.dart | 50 ++++ .../presentation/task_details_pane_test.dart | 17 +- 18 files changed, 803 insertions(+), 132 deletions(-) create mode 100644 lib/src/core/time/provider_date_time.dart create mode 100644 test/core/time/provider_date_time_test.dart create mode 100644 test/features/tasks/presentation/task_details_draft_test.dart diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index f3c3c49..fdde24e 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -348,6 +348,7 @@ authSessionControllerProvider = final calendarRepositoryProvider = Provider((ref) { return CalendarRepository( database: ref.watch(databaseProvider), + localTimeZone: ref.watch(localTimeZoneProvider), onNotificationScheduleChanged: () => ref.read(notificationSchedulerProvider).checkNow(), ); diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index b976a4c..bbd7799 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1025,18 +1025,12 @@ class _BusyMaxCategoryChip extends StatelessWidget { ), ), const SizedBox(width: BusyMaxSpacing.xs), - Tooltip( - message: + _BusyMaxCategoryIconAction( + icon: YaruIcons.window_close, + tooltip: '${MaterialLocalizations.of(context).deleteButtonTooltip} $label', - child: InkResponse( - onTap: onDeleted, - radius: BusyMaxSizes.iconMd, - child: Icon( - YaruIcons.window_close, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), - ), + color: colorScheme.onSurfaceVariant, + onPressed: onDeleted, ), ], ), @@ -1153,7 +1147,7 @@ class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> { textEditingController: widget.controller, focusNode: _focusNode, displayStringForOption: (option) => option, - optionsViewOpenDirection: OptionsViewOpenDirection.up, + optionsViewOpenDirection: OptionsViewOpenDirection.down, optionsBuilder: _categoryOptionsFor, onSelected: widget.onSubmitted, fieldViewBuilder: @@ -1163,7 +1157,7 @@ class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> { controller: controller, focusNode: focusNode, autofocus: true, - decoration: InputDecoration.collapsed( + decoration: busyMaxDropdownDecoration().copyWith( hintText: widget.hintText, ), textInputAction: TextInputAction.done, @@ -1178,24 +1172,18 @@ class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> { }, ), ), - InkResponse( - onTap: () => _submitTypedCategory(widget.controller.text), - radius: BusyMaxSizes.iconMd, - child: Icon( - YaruIcons.checkmark, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), + _BusyMaxCategoryIconAction( + icon: YaruIcons.checkmark, + tooltip: MaterialLocalizations.of(context).okButtonLabel, + color: colorScheme.onSurfaceVariant, + onPressed: () => _submitTypedCategory(widget.controller.text), ), const SizedBox(width: BusyMaxSpacing.xs), - InkResponse( - onTap: widget.onCancel, - radius: BusyMaxSizes.iconMd, - child: Icon( - YaruIcons.window_close, - size: BusyMaxSizes.iconSm, - color: colorScheme.onSurfaceVariant, - ), + _BusyMaxCategoryIconAction( + icon: YaruIcons.window_close, + tooltip: MaterialLocalizations.of(context).cancelButtonLabel, + color: colorScheme.onSurfaceVariant, + onPressed: widget.onCancel, ), ], ), @@ -1242,6 +1230,47 @@ class _BusyMaxCategoryInputChipState extends State<_BusyMaxCategoryInputChip> { } } +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, @@ -1258,52 +1287,99 @@ class _BusyMaxCategoryAutocompleteOptions extends StatelessWidget { } final popupTheme = Theme.of(context).popupMenuTheme; final colorScheme = Theme.of(context).colorScheme; - const width = 180.0; - const menuAffordanceWidth = 36.0; - const labelWidth = width - BusyMaxSpacing.md * 2 - menuAffordanceWidth; - return Align( - alignment: Alignment.topLeft, + 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: popupTheme.color ?? colorScheme.surfaceContainerHigh, - elevation: BusyMaxElevation.popover, - shadowColor: BusyMaxShadow.floatingColor(context), - surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder( + color: Colors.transparent, + borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), + clipBehavior: Clip.antiAlias, + child: InkWell( borderRadius: BorderRadius.circular(BusyMaxRadius.headerButton), - ), - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: width, - maxWidth: width, - maxHeight: 240, - ), + 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(vertical: 4), - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (context, index) { - final option = options[index]; - return SizedBox( - width: width, - child: MenuItemButton( - style: busyMaxDropdownMenuItemStyle(context).copyWith( - fixedSize: const WidgetStatePropertyAll(Size(width, 36)), - ), - onPressed: () => onSelected(option), - child: SizedBox( - width: labelWidth, - child: Text( - option, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - ), - ), + 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, ), - ); - }, + ), + ), ), ), ), 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/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 47f504d..98afe03 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -69,13 +69,16 @@ class CalendarRepository { 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( @@ -346,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, @@ -445,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( @@ -707,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) ?? - startTimeZone ?? '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) ?? + '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; diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 794b7cd..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'; @@ -85,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; } diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 1bf7725..01b1402 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -24,6 +24,7 @@ class ScheduleAgendaView extends StatefulWidget { this.onLoadMore, this.onLoadMoreOverdue, this.onLoadMoreNoDate, + this.onItemAnchorAvailable, }); final ScheduleRange range; @@ -36,6 +37,7 @@ class ScheduleAgendaView extends StatefulWidget { final VoidCallback? onLoadMore; final VoidCallback? onLoadMoreOverdue; final VoidCallback? onLoadMoreNoDate; + final ScheduleItemAnchorCallback? onItemAnchorAvailable; @override State createState() => _ScheduleAgendaViewState(); @@ -107,6 +109,7 @@ class _ScheduleAgendaViewState extends State { for (final item in overdueTasks) _AgendaRow( item: item, + onAnchorAvailable: widget.onItemAnchorAvailable, onTap: (context, [globalPosition]) => widget.onItemSelected(context, item, globalPosition), onTaskCompletionChanged: (completed) => @@ -128,6 +131,7 @@ class _ScheduleAgendaViewState extends State { for (final item in noDateTasks) _AgendaRow( item: item, + onAnchorAvailable: widget.onItemAnchorAvailable, onTap: (context, [globalPosition]) => widget.onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem @@ -151,6 +155,7 @@ class _ScheduleAgendaViewState extends State { for (final item in groups[day]!) _AgendaRow( item: item, + onAnchorAvailable: widget.onItemAnchorAvailable, onTap: (context, [globalPosition]) => widget.onItemSelected(context, item, globalPosition), onTaskCompletionChanged: item is TaskScheduleItem @@ -187,17 +192,27 @@ class _AgendaRow extends StatelessWidget { const _AgendaRow({ required this.item, required this.onTap, + this.onAnchorAvailable, this.onTaskCompletionChanged, }); final ScheduleItem item; final ScheduleItemTapCallback onTap; + final ScheduleItemAnchorCallback? onAnchorAvailable; final ValueChanged? onTaskCompletionChanged; @override Widget build(BuildContext context) { 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 BusyMaxActionRow( title: item.title, diff --git a/lib/src/features/schedule/presentation/schedule_item_selection.dart b/lib/src/features/schedule/presentation/schedule_item_selection.dart index 4e52caf..66f7c76 100644 --- a/lib/src/features/schedule/presentation/schedule_item_selection.dart +++ b/lib/src/features/schedule/presentation/schedule_item_selection.dart @@ -11,3 +11,6 @@ typedef ScheduleItemSelectionCallback = 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_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 855b78d..14e1c75 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -72,6 +72,10 @@ class _ScheduleWorkspaceState extends ConsumerState { 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; @@ -202,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: [ @@ -292,6 +296,7 @@ class _ScheduleWorkspaceState extends ConsumerState { globalPosition: globalPosition, ), ), + onItemAnchorAvailable: _handleItemAnchorAvailable, onTaskCompletionChanged: _setTaskCompleted, ), ), @@ -1175,7 +1180,6 @@ class _ScheduleWorkspaceState extends ConsumerState { void _consumePendingCommand( List sources, List accounts, - List items, ) { final command = ref.watch(scheduleWorkspaceCommandProvider); if (command == null) { @@ -1196,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; @@ -1232,6 +1279,10 @@ 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) @@ -1386,6 +1437,7 @@ class _ScheduleBody extends StatelessWidget { required this.onAgendaLoadMoreOverdue, required this.onAgendaLoadMoreNoDate, required this.onItemSelected, + required this.onItemAnchorAvailable, required this.onTaskCompletionChanged, }); @@ -1413,6 +1465,7 @@ class _ScheduleBody extends StatelessWidget { final VoidCallback? onAgendaLoadMoreOverdue; final VoidCallback? onAgendaLoadMoreNoDate; final ScheduleItemSelectionCallback onItemSelected; + final ScheduleItemAnchorCallback onItemAnchorAvailable; final void Function(TaskScheduleItem item, bool completed) onTaskCompletionChanged; @@ -1484,6 +1537,7 @@ class _ScheduleBody extends StatelessWidget { onLoadMoreOverdue: onAgendaLoadMoreOverdue, onLoadMoreNoDate: onAgendaLoadMoreNoDate, onItemSelected: onItemSelected, + onItemAnchorAvailable: onItemAnchorAvailable, onTaskCompletionChanged: onTaskCompletionChanged, ), }; diff --git a/lib/src/features/tasks/presentation/task_details_draft.dart b/lib/src/features/tasks/presentation/task_details_draft.dart index 6000562..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: _scheduleTimePart(task.microsoftDueDateTime), - microsoftDueTimeZone: task.microsoftDueTimeZone ?? localTimeZone, - microsoftStartDate: _datePart(task.microsoftStartDateTime), - microsoftStartTime: _scheduleTimePart(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 = _scheduleTimePart(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) { @@ -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) { @@ -295,9 +339,9 @@ void _putDateTimePatch( required String timeZoneField, }) { final changed = - date != _datePart(originalDateTime) || - time != _scheduleTimePart(originalDateTime) || - timeZone != originalTimeZone; + date != _providerDatePart(originalDateTime, originalTimeZone) || + time != _providerTimePart(originalDateTime, originalTimeZone) || + timeZone != _editorTimeZone(originalDateTime, originalTimeZone, timeZone); if (!changed) { return; } @@ -343,8 +387,35 @@ String? _timePart(String? value) { return time.substring(0, 5); } -String? _scheduleTimePart(String? value) { - return _timePart(value); +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( diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 13474b3..2efbdf3 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -5,6 +5,7 @@ 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'; @@ -363,7 +364,10 @@ class ScheduleRepository { notes: task.notes ?? task.bodyContent, categories: _stringListFromJson(task.categoriesJson), reminder: task.microsoftIsReminderOn == true - ? DateTime.tryParse(task.microsoftReminderDateTime ?? '') + ? providerDateTimeAsLocal( + task.microsoftReminderDateTime, + task.microsoftReminderTimeZone, + ) : null, sourceName: list?.title, accountDisplayName: accountDisplayNames[task.accountId], @@ -535,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); } @@ -561,6 +565,29 @@ 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 []; 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/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index d45799a..4b59eea 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -505,14 +505,7 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('event-category-input')), 'wo'); await tester.pumpAndSettle(); - await tester.tap( - find - .ancestor( - of: find.text('Work').last, - matching: find.byType(MenuItemButton), - ) - .last, - ); + await tester.tap(find.text('Work').last); await tester.pumpAndSettle(); await tester.tap(_headerButtonFinder('Save')); diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index 0752a71..79849eb 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -283,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( @@ -342,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) @@ -365,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/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 62cc5cf..4c67a17 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -850,6 +850,39 @@ 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; diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index d7174f5..3e325df 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -182,6 +182,55 @@ void main() { 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 { @@ -213,8 +262,8 @@ void main() { providerCalendarId: 'calendar', providerEventId: 'event', title: 'Planning', - startDateTime: '2026-06-11T09:00:00', - endDateTime: '2026-06-11T10:00:00', + startDateTime: '2026-06-11T09:00:00', + endDateTime: '2026-06-11T10:00:00', remindersJson: {'useDefault': true}, ), ); @@ -323,6 +372,43 @@ void main() { 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); diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 4734e63..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 { @@ -417,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, @@ -424,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, 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 5c68752..e270299 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -594,14 +594,15 @@ void main() { await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('task-category-input')), 'wo'); await tester.pumpAndSettle(); - await tester.tap( - find - .ancestor( - of: find.text('Work').last, - matching: find.byType(MenuItemButton), - ) - .last, + 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(); @@ -1222,7 +1223,7 @@ class _FakeTasksRepository implements TasksRepository { microsoftStartTimeZone: 'UTC', microsoftIsReminderOn: reminderOn, microsoftReminderDateTime: reminderOn ? '2026-06-05T09:15:00' : null, - microsoftReminderTimeZone: 'UTC', + microsoftReminderTimeZone: 'America/Vancouver', importance: 'high', categoriesJson: '["Home"]', ), From 63a14af7c9bf36d893bae1d2d04f94441585b8a2 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 07:18:23 -0700 Subject: [PATCH 43/53] Add task creation functionality to compact agenda panel and refactor new task dialog --- .../compact_agenda_controller.dart | 22 ++++ .../presentation/compact_agenda_panel.dart | 119 +++++++++++++++--- .../tasks/presentation/new_task_dialog.dart | 38 ++++-- .../platform/main_window_command_bridge.dart | 1 + .../compact_agenda_panel_test.dart | 10 ++ 5 files changed, 163 insertions(+), 27 deletions(-) diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart index 781675c..e37a8e7 100644 --- a/lib/src/features/schedule/application/compact_agenda_controller.dart +++ b/lib/src/features/schedule/application/compact_agenda_controller.dart @@ -17,6 +17,28 @@ class CompactAgendaController { 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); + + try { + await const MainWindowCommandClient().requestTaskSync(accountId); + } on Object { + // The pending operation remains queued and will sync when the main engine + // is available. + } + + _ref.invalidate(compactAgendaDataProvider); + _ref.invalidate(compactAgendaDataForQueryProvider); + } + Future setTaskCompleted(TaskScheduleItem item, bool completed) async { final fields = { 'status': completed ? 'completed' : 'needsAction', diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index a2a6128..acc4a62 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -6,6 +6,7 @@ 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'; @@ -13,6 +14,8 @@ import '../../../l10n/l10n.dart'; import '../../../platform/main_window_command_client.dart'; import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_projection.dart'; +import '../../tasks/data/tasks_repository.dart'; +import '../../tasks/presentation/new_task_dialog.dart'; import '../application/compact_agenda_controller.dart'; import '../application/compact_agenda_data.dart'; import '../application/compact_agenda_sections.dart'; @@ -59,6 +62,7 @@ class _CompactAgendaPanelState extends ConsumerState { CompactAgendaData? _lastAgendaData; bool _bodyScrolledUnderHeader = false; bool _bodyScrolledUnderFooter = false; + bool _creatingTask = false; @override Widget build(BuildContext context) { @@ -109,20 +113,12 @@ class _CompactAgendaPanelState extends ConsumerState { return const SizedBox.expand(); } - 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: Column( + final child = _creatingTask + ? _CompactAgendaNewTaskView( + onCancel: _closeNewTaskEditor, + onSubmitted: _createTask, + ) + : Column( children: [ _CompactAgendaHeader( data: data.valueOrNull, @@ -156,7 +152,22 @@ class _CompactAgendaPanelState extends ConsumerState { ), _CompactAgendaBottomBar(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, ), ), ); @@ -334,8 +345,37 @@ class _CompactAgendaPanelState extends ConsumerState { await callback(); return; } - await const MainWindowCommandClient().newTask(); - await windowManager.hide(); + setState(() { + _creatingTask = true; + _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(); } Future _refresh() async { @@ -540,6 +580,53 @@ class _CompactHeaderButton extends StatelessWidget { } } +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(); diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 3c3fd5a..1b5ce4e 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -29,6 +31,9 @@ class NewTaskDraft { List get categories => input.categories; } +typedef TaskCategorySuggestionsStreamForAccount = + Stream> Function(String accountId); + Future showBusyMaxNewTaskDialog( BuildContext context, { required WidgetRef ref, @@ -45,34 +50,43 @@ Future showBusyMaxNewTaskDialog( 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, }); final List accounts; final String? initialAccountId; final String? initialListId; final DateTime? initialDueUtc; + final FutureOr Function(NewTaskDraft draft) onSubmitted; + final VoidCallback onCancel; + final TaskCategorySuggestionsStreamForAccount? categorySuggestionsForAccount; @override - ConsumerState<_NewTaskDialog> createState() => _NewTaskDialogState(); + ConsumerState createState() => _NewTaskEditorPanelState(); } -class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { +class _NewTaskEditorPanelState extends ConsumerState { String? _accountId; String? _taskListId; TaskDetailsDraft? _draftSnapshot; @@ -97,12 +111,14 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { final repository = ref.watch( taskListsRepositoryForAccountProvider(accountId), ); - final tasksRepository = ref.watch( - tasksRepositoryForAccountProvider(accountId), - ); + final categorySuggestionsStream = + widget.categorySuggestionsForAccount?.call(accountId) ?? + ref + .watch(tasksRepositoryForAccountProvider(accountId)) + .watchCategorySuggestions(); return StreamBuilder>( - stream: tasksRepository.watchCategorySuggestions(), + stream: categorySuggestionsStream, builder: (context, categorySnapshot) { final categorySuggestions = categorySnapshot.data ?? const []; return StreamBuilder>( @@ -151,7 +167,7 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { onCreateSubtask: (_) {}, onMoveToTop: () {}, onDelete: () async {}, - onCancel: () => Navigator.of(context).pop(), + onCancel: widget.onCancel, ); }, ); @@ -266,7 +282,7 @@ class _NewTaskDialogState extends ConsumerState<_NewTaskDialog> { draft.title.trim().isEmpty) { return; } - Navigator.of(context).pop( + await widget.onSubmitted( NewTaskDraft( accountId: accountId, taskListId: draft.taskListId, diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart index a346f37..f0255e5 100644 --- a/lib/src/platform/main_window_command_bridge.dart +++ b/lib/src/platform/main_window_command_bridge.dart @@ -120,6 +120,7 @@ class _MainWindowCommandBridgeState ref .read(pendingMutationSyncRequesterForAccountProvider(accountId)) .request(); + unawaited(ref.read(notificationSchedulerProvider).checkNow()); return true; } diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index c85344e..727f229 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -71,6 +71,16 @@ void main() { 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')); + }); + testWidgets('no-date tasks render in a No date section', (tester) async { await tester.pumpWidget( _testPanel(data: _data(today, items: [_task('Plan someday')])), From d25ac3084c4c216c3ab4570f3f497d5165eb9bda Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 07:39:39 -0700 Subject: [PATCH 44/53] Add native date picker support and enhance date handling in task dialogs --- .../desktop_date_time_fields.dart | 206 ++++++++++++++++-- .../tasks/presentation/new_task_dialog.dart | 3 + .../presentation/task_details_editor.dart | 5 + linux/runner/my_application.cc | 27 ++- test/app/native_ui_audit_test.dart | 4 + .../compact_agenda_panel_test.dart | 1 + .../presentation/task_details_pane_test.dart | 63 +++++- 7 files changed, 280 insertions(+), 29 deletions(-) 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 0320e35..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)); } } @@ -607,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; diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 1b5ce4e..a8eca2c 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -72,6 +72,7 @@ class NewTaskEditorPanel extends ConsumerStatefulWidget { this.initialListId, this.initialDueUtc, this.categorySuggestionsForAccount, + this.useNativeDatePicker = true, }); final List accounts; @@ -81,6 +82,7 @@ class NewTaskEditorPanel extends ConsumerStatefulWidget { final FutureOr Function(NewTaskDraft draft) onSubmitted; final VoidCallback onCancel; final TaskCategorySuggestionsStreamForAccount? categorySuggestionsForAccount; + final bool useNativeDatePicker; @override ConsumerState createState() => _NewTaskEditorPanelState(); @@ -156,6 +158,7 @@ class _NewTaskEditorPanelState extends ConsumerState { showAdvancedActions: false, showDeleteAction: false, confirmTaskSwitch: false, + useNativeDatePicker: widget.useNativeDatePicker, categorySuggestions: categorySuggestions, canSaveDraft: (draft) => draft.taskListId.isNotEmpty, onDraftChanged: (draft) { diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index c6a9f06..55c0f2a 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -46,6 +46,7 @@ class TaskDetailsEditor extends StatefulWidget { this.showAdvancedActions = true, this.showDeleteAction = true, this.confirmTaskSwitch = true, + this.useNativeDatePicker = true, this.canSaveDraft, }); @@ -81,6 +82,7 @@ class TaskDetailsEditor extends StatefulWidget { final bool showAdvancedActions; final bool showDeleteAction; final bool confirmTaskSwitch; + final bool useNativeDatePicker; final bool Function(TaskDetailsDraft draft)? canSaveDraft; @override @@ -222,6 +224,7 @@ class _TaskDetailsEditorState extends State { emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(dueDate: value)), + useNativePicker: widget.useNativeDatePicker, onClear: () => _updateDraft( draft.copyWith( dueDate: null, @@ -423,6 +426,7 @@ 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), ), @@ -766,6 +770,7 @@ class _TaskDetailsEditorState extends State { emptyLabel: l10n.noneValue, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderDate: value)), + useNativePicker: widget.useNativeDatePicker, onClear: () => _updateDraft(draft.copyWith(microsoftReminderDate: null)), ), diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index fe908a0..39c9adf 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -271,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) { @@ -2645,6 +2659,7 @@ static void configure_compact_agenda_subwindow(FlPluginRegistry* registry) { 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. diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 4dfe552..0d05417 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -202,6 +202,10 @@ void main() { 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)')); diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 727f229..3c5a4bd 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -79,6 +79,7 @@ void main() { expect(source, contains('NewTaskEditorPanel')); expect(source, isNot(contains('MainWindowCommandClient().newTask'))); expect(source, contains('_creatingTask = true')); + expect(source, isNot(contains('useNativeDatePicker: false'))); }); testWidgets('no-date tasks render in a No date section', (tester) async { diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index e270299..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 { @@ -736,6 +738,65 @@ 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 in-app time entry instead of custom picker channel', (tester) async { From 34e0dd61e619c22147cf9288f5ab773095278f6b Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 08:07:10 -0700 Subject: [PATCH 45/53] Add event management functionality to compact agenda panel --- .../compact_agenda_controller.dart | 58 ++- .../presentation/compact_agenda_panel.dart | 426 +++++++++++++++++- .../tasks/presentation/task_details_pane.dart | 89 +++- .../platform/main_window_command_bridge.dart | 26 ++ .../platform/main_window_command_client.dart | 7 + .../compact_agenda_panel_test.dart | 14 + 6 files changed, 589 insertions(+), 31 deletions(-) diff --git a/lib/src/features/schedule/application/compact_agenda_controller.dart b/lib/src/features/schedule/application/compact_agenda_controller.dart index e37a8e7..ff10188 100644 --- a/lib/src/features/schedule/application/compact_agenda_controller.dart +++ b/lib/src/features/schedule/application/compact_agenda_controller.dart @@ -3,6 +3,8 @@ 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'; @@ -28,13 +30,38 @@ class CompactAgendaController { ); await repository.createTask(taskListId, input); - try { - await const MainWindowCommandClient().requestTaskSync(accountId); - } on Object { - // The pending operation remains queued and will sync when the main engine - // is available. - } + 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); } @@ -51,15 +78,28 @@ class CompactAgendaController { ); 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(item.accountId); + await const MainWindowCommandClient().requestTaskSync(accountId); } on Object { // The pending operation remains queued and will sync when the main engine // is available. } + } - _ref.invalidate(compactAgendaDataProvider); - _ref.invalidate(compactAgendaDataForQueryProvider); + 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. + } } } diff --git a/lib/src/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index acc4a62..5b76276 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -14,8 +14,14 @@ 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'; @@ -63,6 +69,9 @@ class _CompactAgendaPanelState extends ConsumerState { bool _bodyScrolledUnderHeader = false; bool _bodyScrolledUnderFooter = false; bool _creatingTask = false; + bool _creatingEvent = false; + TaskScheduleItem? _editingTask; + EventEditorDraft? _editingEventDraft; @override Widget build(BuildContext context) { @@ -113,7 +122,21 @@ class _CompactAgendaPanelState extends ConsumerState { return const SizedBox.expand(); } - final child = _creatingTask + 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, @@ -150,7 +173,10 @@ class _CompactAgendaPanelState extends ConsumerState { ], ), ), - _CompactAgendaBottomBar(onNewTask: _newTask), + _CompactAgendaBottomBar( + onNewEvent: _newEvent, + onNewTask: _newTask, + ), ], ); @@ -347,6 +373,20 @@ class _CompactAgendaPanelState extends ConsumerState { } 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; }); @@ -378,6 +418,73 @@ class _CompactAgendaPanelState extends ConsumerState { _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) { @@ -419,8 +526,14 @@ class _CompactAgendaPanelState extends ConsumerState { case ScheduleItemDetailsAction.export: await _exportItem(item); case ScheduleItemDetailsAction.edit: - await const MainWindowCommandClient().openScheduleItem(item); - await windowManager.hide(); + if (item is TaskScheduleItem) { + _openTaskEditor(item); + } else if (item is CalendarScheduleItem) { + _openEventEditor(item); + } else { + await const MainWindowCommandClient().openScheduleItem(item); + await windowManager.hide(); + } } } @@ -484,6 +597,129 @@ class _CompactAgendaPanelState extends ConsumerState { 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 { @@ -580,6 +816,175 @@ class _CompactHeaderButton extends StatelessWidget { } } +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) { + return ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: TaskDetailsPane( + accountId: item.accountId, + taskListId: item.sourceId, + taskId: item.id, + tasksRepositoryForAccount: (accountId) => TasksRepository( + database: ref.read(databaseProvider), + accountId: accountId, + ), + taskListsRepositoryForAccount: (accountId) => TaskListsRepository( + database: ref.read(databaseProvider), + accountId: accountId, + ), + onTaskMutationCommitted: (accountId) => + ref.read(compactAgendaControllerProvider).taskMutated(accountId), + onClose: onClose, + ), + ); + } +} + +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, @@ -987,8 +1392,12 @@ class _MoreBucketRow extends StatelessWidget { } class _CompactAgendaBottomBar extends StatelessWidget { - const _CompactAgendaBottomBar({required this.onNewTask}); + const _CompactAgendaBottomBar({ + required this.onNewEvent, + required this.onNewTask, + }); + final Future Function() onNewEvent; final Future Function() onNewTask; @override @@ -999,6 +1408,13 @@ class _CompactAgendaBottomBar extends StatelessWidget { decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), child: Row( children: [ + Expanded( + child: BusyMaxPushButton.outlined( + onPressed: () => unawaited(onNewEvent()), + child: Text(context.l10n.newEvent), + ), + ), + const SizedBox(width: BusyMaxSpacing.sm), Expanded( child: BusyMaxPushButton.filled( onPressed: () => unawaited(onNewTask()), diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 3f6510c..6d62604 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -14,12 +14,21 @@ 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, @@ -28,6 +37,9 @@ class TaskDetailsPane extends ConsumerStatefulWidget { 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; @@ -55,6 +67,10 @@ class _TaskDetailsPaneState extends ConsumerState { Stream>? _categorySuggestionsStream; TaskListsRepository? _listsStreamRepository; Stream>? _listsStream; + String? _customTasksRepositoryAccountId; + TasksRepository? _customTasksRepository; + String? _customTaskListsRepositoryAccountId; + TaskListsRepository? _customTaskListsRepository; var _editorDirty = false; var _confirmingTaskSwitch = false; @@ -101,12 +117,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); @@ -187,12 +199,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( @@ -202,6 +216,10 @@ class _TaskDetailsPaneState extends ConsumerState { destinationTaskListId: draft.taskListId, ), ); + mutated = true; + } + if (mutated) { + await widget.onTaskMutationCommitted?.call(task.accountId); } } @@ -230,22 +248,14 @@ 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(), @@ -255,6 +265,25 @@ class _TaskDetailsPaneState extends ConsumerState { ); } + 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; @@ -283,6 +312,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, diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart index f0255e5..9e21d24 100644 --- a/lib/src/platform/main_window_command_bridge.dart +++ b/lib/src/platform/main_window_command_bridge.dart @@ -49,6 +49,8 @@ class _MainWindowCommandBridgeState return true; case 'busymax.main.requestTaskSync': return _requestTaskSync(call.arguments); + case 'busymax.main.requestCalendarSync': + return _requestCalendarSync(call.arguments); } throw MissingPluginException('Not implemented: ${call.method}'); @@ -124,6 +126,30 @@ class _MainWindowCommandBridgeState 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 index 01f0436..ad24792 100644 --- a/lib/src/platform/main_window_command_client.dart +++ b/lib/src/platform/main_window_command_client.dart @@ -47,4 +47,11 @@ class MainWindowCommandClient { {'accountId': accountId}, ); } + + Future requestCalendarSync(String accountId) async { + await busyMaxMainWindowChannel.invokeMethod( + 'busymax.main.requestCalendarSync', + {'accountId': accountId}, + ); + } } diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 3c5a4bd..9925353 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -82,6 +82,20 @@ void main() { 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, 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')])), From 91c2976bc8808b988a8dcfa5c2004a981f26f413 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 08:15:54 -0700 Subject: [PATCH 46/53] Add dialog barrier color support to task and event dialogs in compact agenda --- lib/src/app/busymax_dialogs.dart | 4 ++++ .../presentation/compact_agenda_panel.dart | 20 +++++++++---------- .../presentation/task_details_editor.dart | 6 ++++++ .../tasks/presentation/task_details_pane.dart | 3 +++ .../compact_agenda_panel_test.dart | 1 + 5 files changed, 23 insertions(+), 11 deletions(-) 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/features/schedule/presentation/compact_agenda_panel.dart b/lib/src/features/schedule/presentation/compact_agenda_panel.dart index 5b76276..b8ea2a4 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_panel.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_panel.dart @@ -827,23 +827,21 @@ class _CompactAgendaTaskEditorView extends ConsumerWidget { @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: ref.read(databaseProvider), - accountId: accountId, - ), - taskListsRepositoryForAccount: (accountId) => TaskListsRepository( - database: ref.read(databaseProvider), - accountId: accountId, - ), - onTaskMutationCommitted: (accountId) => - ref.read(compactAgendaControllerProvider).taskMutated(accountId), + tasksRepositoryForAccount: (accountId) => + TasksRepository(database: database, accountId: accountId), + taskListsRepositoryForAccount: (accountId) => + TaskListsRepository(database: database, accountId: accountId), + onTaskMutationCommitted: controller.taskMutated, onClose: onClose, + dialogBarrierColor: Colors.transparent, ), ); } @@ -1409,7 +1407,7 @@ class _CompactAgendaBottomBar extends StatelessWidget { child: Row( children: [ Expanded( - child: BusyMaxPushButton.outlined( + child: BusyMaxPushButton.filled( onPressed: () => unawaited(onNewEvent()), child: Text(context.l10n.newEvent), ), diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 55c0f2a..1f599e2 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -47,6 +47,7 @@ class TaskDetailsEditor extends StatefulWidget { this.showDeleteAction = true, this.confirmTaskSwitch = true, this.useNativeDatePicker = true, + this.dialogBarrierColor, this.canSaveDraft, }); @@ -83,6 +84,7 @@ class TaskDetailsEditor extends StatefulWidget { final bool showDeleteAction; final bool confirmTaskSwitch; final bool useNativeDatePicker; + final Color? dialogBarrierColor; final bool Function(TaskDetailsDraft draft)? canSaveDraft; @override @@ -624,6 +626,7 @@ class _TaskDetailsEditorState extends State { message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discard, destructive: true, + barrierColor: widget.dialogBarrierColor, ); if (!discard || !mounted) { return; @@ -645,6 +648,7 @@ class _TaskDetailsEditorState extends State { message: context.l10n.discardChangesConfirmation, confirmLabel: context.l10n.discard, destructive: true, + barrierColor: widget.dialogBarrierColor, ); if (!mounted) { return; @@ -663,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; @@ -677,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(); diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 6d62604..73857bc 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -32,6 +32,7 @@ class TaskDetailsPane extends ConsumerStatefulWidget { this.onClose, this.onTaskSwitchCancelled, this.onDirtyChanged, + this.dialogBarrierColor, }); final String accountId; @@ -43,6 +44,7 @@ class TaskDetailsPane extends ConsumerStatefulWidget { final VoidCallback? onClose; final ValueChanged? onTaskSwitchCancelled; final ValueChanged? onDirtyChanged; + final Color? dialogBarrierColor; @override ConsumerState createState() => _TaskDetailsPaneState(); @@ -262,6 +264,7 @@ class _TaskDetailsPaneState extends ConsumerState { onSaved: () => widget.onClose?.call(), onTaskSwitchCancelled: widget.onTaskSwitchCancelled, onDirtyChanged: _setEditorDirty, + dialogBarrierColor: widget.dialogBarrierColor, ); } diff --git a/test/features/schedule/presentation/compact_agenda_panel_test.dart b/test/features/schedule/presentation/compact_agenda_panel_test.dart index 9925353..1c55573 100644 --- a/test/features/schedule/presentation/compact_agenda_panel_test.dart +++ b/test/features/schedule/presentation/compact_agenda_panel_test.dart @@ -93,6 +93,7 @@ void main() { 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'))); }); From 8eab244a647accac9df9041431dd4f2d086d87d8 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 15:48:32 -0700 Subject: [PATCH 47/53] Replace PNG logo with SVG version for improved scalability and quality --- assets/branding/busymax-logo.png | Bin 372462 -> 0 bytes assets/branding/busymax-logo.svg | 67 ++++++++++++++++++ lib/src/app/busymax_about_dialog.dart | 2 +- .../presentation/task_lists_sidebar.dart | 2 +- lib/src/platform/busymax_tray_service.dart | 2 +- linux/CMakeLists.txt | 4 +- linux/runner/my_application.cc | 2 +- pubspec.yaml | 2 +- test/app/native_ui_audit_test.dart | 6 +- 9 files changed, 79 insertions(+), 8 deletions(-) delete mode 100644 assets/branding/busymax-logo.png create mode 100644 assets/branding/busymax-logo.svg diff --git a/assets/branding/busymax-logo.png b/assets/branding/busymax-logo.png deleted file mode 100644 index 6749c88364d87f5236392f9f88f8efc882ff5df3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 372462 zcmeEtQ*$m15A9RiyS8oHc6WDe+s1DD)VA$**S2ljw(awN=Qo_oGnq_gtz2Yg&18~` zBwR^B5&;$m761SsNK1*S005vA|8r=_{}}UWVF>_$v&KtR!$rl=oyft--pta*l*q-? z!Ia3l8}s|` zpVsdqD8Db-+Ydos!F%>Q_OI@@mlJZvr0)sb@g4mEzxTf$-y1IrJA`OIM=|UAm=T}f zHSWI_z8HJf1%Enk>=S8!1g~3PU$AFmostGzpr(chTJsW zh4bq%pn`e7<65Z89eQTo_<=cn{ zJsS7oyU*{W_Ylq}>CBf#6hfbZ|NbSg7yiPE)j#)vW-eHq4M`Tv@4OZm>Q$}kne*z= zwB{ji(Rq;bMy_Clr`WYpPVSi8F(qO*Qip@&UGx6P<4kw>2tC6-z7};jFvSx?AN%{a z9=#)LqV@*=%)@1d@wxZ#oxJ(xjGW;E^4Ffj{me!Fy&o# zf9A@DIfMNerr2pu--qlT;A=br}c{I=1S=JaGqUM>mVEnQN;UqOhMV^k$DSZp z-co_PGe^u(5`RO_6P{dg3_bp0f(NXpG4>eNl@Q71Q)EQrt`#4cI4A^%b9p_e{!&49 z)qUgT6TKkEOZTog+DkRNo~>~?Z2$GPX*<5qehH*Vo{ zUS;G9Wj&(iNMyhCrYeY{)zllZSa1M-krd_QO$(%iXM~6xLaIb68P_31R`r}4(YAD7 zKUM&{HS}!-k)hpcVN~dya|SDS&UK%0iGzRv`Gphz#$lEEDgpsL(J(88Jzv?}dlUj) zY9bc?J>%n?JL~%aUcdJ=f(A)4R;Y`8-!aLB6N8*1jcJgX1ei`S@Pj>D#4IaWs;rdL zsT)n1c?t1QewV0AN8ne1udeveO3ovwyTZaIuKyZ3u#CUQQVo1H9f{k^-|DtaP;UJT z6hG)(1H2DBttQbLDRCy_hR7*^%#2X)GH=zfb-Nno z^OhW{*20vey_gqO`eEyXfmOV|>k=#}eli?yhxqwGGM=O;AP+Py?)FUn!*|%?Mo6{F zHSE@$LC~nPAsZrrAp|GHXHFl9yLUosN8Q3)lm$XAU6SX>>SD{hl7GgGCLE^#w^atM zPnKiYjfslHI@}O=o~e4JuBIMHVxK}gp94--b8}2N2Yv<0P#PnBDZ3iO@sBal^rBqI zz*S*RviH%MXc_7)1u5(=24`JAg_ips6f+2%+}n8>+CIMsO_M9@Yq^03E?V{i0|OT_ zM4}?KMmX5O2$HFcM&ACz6hX^LZ~QbP7+@^vsk&?8wv@5kAg021x5#5Q1_Z_;A=`-j z(Ya@iI($$cI=Ah&{@xMycCEA=P#{>uD0OJ^v0@K94R;hujUMTDTSF>C-Zc0x&SMkl zb!!mc(dM>39ST9S0MaX#6<|ws_$baWiOex1z0leT`E_5<18V3k!W{#LszfI7$pcLC z2uj@MIKU??oHkI7XtRupsyCjqGn!G~;Tf~IcQ>m=6vD6oMw=m&%IMdqKC=$|f1ii3IbAoRQP%Wl2|1a^aj+62tvO-=xF$i;+@Gt6rq*&Gxi$vwIvzfN( z8n*(RgJFTHNWWLnV0^>@*W`^R*=P5$QsWSJggc zn_vUm)zNa^h9Lt%4p#$wco=mdDY8JFiXGN>ByMp}L#&IOt!XzrzeccuMFB10> z;BS2y#EW@=`PjTcdO#2nxRR=XhniNWF~4G_MX9}10yu0?s0Xp|vIwp(btdGyA@@3r z3PneJiV&hm5kMjgoTNI4A$EWL1V8=6oyX(?I3`#>ZSQ)k=kZAaYFOCPLT?$`eMn^y zPHPEC7(z_U9nL*kLxd$=3MVi&kM5u5ugQY46?JL4eqmC%Gpa#azvIgbLkZi8)KH%R zg1%3Ge)ZxNw)B`#tAj5SX~ITY;5~MDaJNiq){}w;drsAjL@k0W7t9x{`UYQXq-#Fy z>URXG$}15jCal*DVLADWv>e-wReU{g1DZv3Nw^y63N>SSO+X`zvJsd053Nv?jc6}O z-k`KFGeexs_T(WhjBf#J4@hcl8+?M@RA3x*n2`XlUMxo0*`x!Lp)oIwO+#Qg3hO_P zkyK_8$b1tl4zm5t{!E8(A9;gGpjmaG93HvbS;T21dD(B|ak<_z4~}{Ed?Dg*bk;Qf zzOg`ExH#+>Xg)@wyUzU&)3Za?QJHVAFbj%Sd3(Y9;DczLA*C+t-F+wR$LF2~1ycL~ z+}%S^bPRXc)J29`dJ+f6s*Y-0`shZlcwrD~>P-a-0y{~@vRI|N)|stq=hl7YbAn-E{9!bDzZl+|{Yw4r(1af?iZtnqCVG04OKOhPzY z?C|3iEBH)vl;a*w$w=UtlH8zoA%&wAYH+;iZ3$Nk;i#@|-veV4buqYCRTuS;QSO6; zVNCFs8=VCD3XKvCXNab00+d@ewUNzdip$i)?LPQ%!P2xB8ggSY9PD~jXMZVI(P--A zm0PyFxPiV!Tq=|xQer|#ZdA8@iU|)m(s#`-mb~FsR3~ue@32c!+C217lN3-#Uu zDpRckGZa)X#edboMx+B<5Kac6Bg;_LygZ~(Nw5}{SZ&1l`BM(U_4OR#fzG}2V0E4M z@)p7Z$}rt1Qbk+gCJ}+L{ia6Nrl4u4kWc=M4_4^B0nsaK`2C=-?PGz{UC4SF88JNE zC#FTryrT^gh$NG@ipMZ&#d`%x-^pa8K4B)H@I^y|-4-vsTvlf34+BsJ00%Q%$F>da zIaO?fhHqjIVrIuRVqu>&e7^G82$~`A{2Y|xG_$kaZI2*Wvm;JO%iyp@svOd7&B{J* z{aZ|!C%IerBna#~yctD~U*)kHPaA1BQdIl9N+D4WU<1}wH&efc8gqR6N=iBGs;Q*1 z``z8;b}u!TVj^IZ-3fa4lhdyi_2j6=YN%8j(K zl^~EpYyRPpzQntXuIM6ItiioxpnWFW0eqx>!sZr)KX9|^acsLxVFD^F2TBeCjJI1| zS3YsyK!NkenqP+STb(-TEHgQ!0ynLC~%-pTQ z)Y!zkm8$?BCP{%{kar1kNXmkKRZ-rxw^G64{ae z#0?*++}{r%XwOd02Zb<(IWjeoTH6JZcfLDyhIo5E`tbZ#+P_Z-S%>a=jkVh-(ZdaM zhy20Qf0uwRKw@{U(DjUw!L;dpIlJpz%{09d+9R^hf{}8@nDzj9{5$)U=#8b};pHxR znHu4=EG)uO3W_N#B3p$V1G#YJVMRN8=8eWrv1L6ULX6^%02=1Y_kFdA!&2qyj>|6V z6%4_s`xgk3Zau=D5JdGTvlfA~B7KFW%l#ds+tqDIXF&p|Fa0ogD!&uVGrBT9LUfUcHnJx<2wSP`BqFt|o8H3*X zi|t=OjyqI}e?Y4{I2N)$mngz{nZ1dJvMPW*K7Yg=wg1L9{}}t?1%y;sOs7#T;jsrr z}XqX%KWBITQvou@iPTadW zWaO!@?s@_Yaxda;Nrl2d$nm6gqM^fBX#1AwQ{;T$a=MMuW~bq~Ma*gpUH{u;=HawR zb*V7ecnHHXK~VE{`A7RJn;TKlpOh4Y{_K*f={*V9(bB}En*q7A{#1uI{ix1OoFIc@ zmE+V;$QBa{Fj0)MTA=Fik2nzzH%@+@#xYpNdts4j9Sv}}N+UJMJM8L8nY2tPF36@7!OO!=PrrNi zVfCz-D#C)Wh%3MswNyz-!*N^=)#bHzcw$1{jejKxp!hcv{|r1JeCe_pftJs}ckfIv zZfi<@hL`S`;yg&n8IHr8g1A%CaUIGvrdkvWG~M0QbcDo#9V0(&0Mep)K6D>ykOc3yjH$m|$>V+w&UC6fU}nQjNlsv$ zeXq22PgDtg=D@zl$S_yFFnOk1<2*QtndxFW$w<|xNM$dGqV}Ntu@=ECs*H&~ zsyyZ7i3xFGXop445Es6!X#W1n%}HVwlk4qC#qbcjy*>upcB!Fm>11*B9-Y>>?JuIp zYrgw~c^mB^$ui(S75t_ikD0G~bZRu-8Y)C(cF05~$p@-AY23@W$*`OPyO?s3(#1q- z+|24v;S=zJWLw6ro9T*f=(HmQJDy)y6K-1~>OOc2lH>6i`b!ve3H1TLHWZOT6e_(U z0Q)C|G<|I;AXR8mT+*ut*0Pq7+GzH%JBnGvuK;BOI`afF!}yYAh8YG0e2o6EX}mX%DGZG z9~Gx0Khu_KRdnTAf{a}B$4|YWeaS?Y-uYHH!zO*3WBVYWiKAlaajjQ%X^O`=Sz6#) zX8h)y%u~rRtT3(bd8e!@`><4!(KO9;en$`B*d)2FY7*$kOtawS7|77<1J%B$a((2V zP0Ue#mOwbj01(=KQ`E4Aa*15blrRWULME=Ew^a;vL6}|8v)-J0eB>9`Hn(8};pxtq zT7t-D3YT=WU*2yTf;e>eM_O;^UXYww=+()CydVcblVT31ht;{J&|@K4F0~UHIS51D zSY9i#(8SdMOJBQOP5Nqseuy`U?Yz2va|m$W*C?3%GigpP)ILV3kmls)S{po}r*|p% z7XDEsV7jJQ_tT1Gx2_(+2Z@=^A!HU*czVacY;WPc_B8+gg``j$8l>mevMt@}dJkCa zv{^~ziC3krQ7J_wlX5+iYls^{srm;T!kwoZHnT{tX3OY0dTh{*lI8C1_225r)`aoi z-1 z!o!u5mW*({mBs+K1KnUl&}TfJPq&`!2+_XB$#p#zQ7JX#9^uzo+1+>%7#0u2Q@u5K zfAGi)$hcv#g|O&UAf`hP?ccCd5xcKWS}R!7nQTd+hCZ7#x<#qapl_>HkC3v~w8~4o zJu3`UJ1CNMw0>J5?I;zq6t^iI2bxHc7L{ZuVJ76yZKa85%T$Tj zbxmSCmV+gnZCt4??+yC(i0N2(Y4F*>SFeM><^)QNfpb7;hHSv; zO-#Qm<}Xl1$TV1zeuZKU6fVigYv8IVj%d*2gv%S&_{k4*Wu(;jhc+$PNifQm$u|bp zw#nQcmZ;Ve-Dbe1Osx9`>XYySD1RQ=VhCKP6as@zyf-vG*Gb4Q6IH|bGkA#;~ zDL%5ZziTPRw*6pBzLHYOgPgS6?Fy-4zWnjXj*)`?`j?JGZyTpBLMT{Q>kx6eu4tf` zW<4s-uaci-7e{rLnx>0+4$!P;fn6(ACrekWeYRck#X`tH831i7+d4vHcO$f1H*7?y zP&0_dG+bB$QXY7Lv{j>czE2{OL^D6H1k9J{)9yVvrJXd=}Es{thN zlnz+AEgGwD(zX8D%TL!F-ntWGDn;k18(3&{dx<7$Q?gmAwAiu4E&0@B$h^_ntMmOw z--~Cav=Y#=3D+8CWRWqtST%@-*jIwN1(Cs{?JnlKFn~uxM0g#+<2e_CoY%<>YT1s7wM=!90-O`PtC+ zBJs7o$mA1;CV2l?gdv{Thjcf4e3;Afw^cM2vmBrcgm~^Sey#UM7El0uzu7nBfxkyL(_F-9iM0Z1DqJ|G0W{?C%!7sjhE@AITG% zFCI~_V^Qzx2xXlKI@cwF|5@#!Kx1^teLg40Nr?=4X5OPP6E`*5RlrHVqS91b0+Ieu zM@!j>Ww$*$R|pX1LA3W1yA>lNr)xsJ=)6kY7?oGW2IiKb;IhNcA%hk0CGqOe41Su& z_alZ78qR!OKzk0X>>taFPmcW)*tgkh(wR8gwmY_wSUh#6$5g>TFN;r}EgdrW^P@OQ&?E3KDDAPuZLKWiN;TAM8bLsN)f{~EnHTR{=`_81N z31h!#6ZlA*%=VW%z~fei7tz|L(*b%KW)*5%;9Qeou%JPe)9mkNj!arbo(` zPwM$v%?+*{*I|MTIbt;n!T%7H78?y^_zWx6an*$#$7~eDCu|=V$!+9UxE?S@#!A$u ztH|#x4?$n*S3$g05{*Ki+=}BoHQc~Zy{`RU?rq3 zBA5ZwRk7LrgX>a^p)s1;2C7a*Zk0_+p+gDkIu4ep6@tDJZyf8`u$>|cT5y;(?l*Bk z7xq8KBKr-xupkV2PfjIEJvilpUC~z~^zA4VAYOYmm+si&MquDZ3Okc87VSzjc~*?E z$Z~xDDtMHBz!uS(TqUVjZJIyqzFEA20V8J&2!v(Tr{y4#tbc)Eb&^&Y^H787j;t>_ zVbpCP*`T4SM65MokOD)>IOb`iC&%{FQ%>i}@RTCv;f+z zxXdL94j2P))O9&Do(nEzkDn#}UP4EKX*Mvy&eY=0C|JK@fq^$`S^9!RL}ct&G+NGl zg9S2mOHq`0XzO^{;z6hKn=;b$2fNTFN-1mK2XXFF?yozrH3N{@s(FOu+z;+1)JYpd zrgV(o7@0t{zw>&>$a4@XA&`__Zl|wESj`A;y8}xw1luYb8D&{G{HdM29R8r*N@~kU zg{(WLqNr9L{ALb@GO;>|ChcC~3;_rAG_t>3G3-ElU_YPkHoJennG0&!Z33Z#gFo~1~2aXr`G`J5`65*{mp~zy0Vmae! z4Yk_2Y%Gf+{`;-9{B%LO48eB3;;p+08A$ORfj1W1ZSSlQzid;4r)JauS2hq$P-{B^ z$OhYDe<9#-F4B~fqk;N3w(cD~u^D*jv?0@y8jV^N!dlIQ$_zS<$v>E9eSr7urSx4_uS+w@Fbsj` znqR0Bnn@Ku8EKzPx?H{+iHU+h@ zC0oH_`P=GE4aNrhj57WQP*lI*x&rJFqRT$ly5^lTTGVc}A6tJfqi@u(7jY(BmNzz| zCUq1R!QZQbYNGv*iEj+p34G6%i-zXU#4o*{!qrEEL5yar#UVKkoFnbO zYgJE52rGAkc3BTvcmY9vDxT`%z$w{h-yU`yR}s*ft=-kcaa)CpR3EVh^>YZJaL!@E zJLW4r(Vc?EIqRX)Lrmnx%`VveIM@#38hiJQ?%>6<GIkAiSU8ajEsF^ABmGPeoN=wtHIR}b-6m|GKkJO1 z2)u`hAUvBXQ^@d~C9C>}(YqmALe0uqy{rZGno-U6mq~{?HG2wetjEQ&<|WtZAQGud zamZ)Q(+)VAlo)IN_Lr?Jp`GV-hcy!o_p65k4t<4s?86AW!sT^qe~tVQbPDBn7;22q zXXV}M>8uMZNE{4pGd&}tpI&&NpEG&)VV{MbW|oVstTwpQmQ-+~KOET;u)-zg+cNrH z6!4%u8A7@gQ+pDMKR*rT7ICmu)VC(z&Tbw;n{xmg;{LEPMO> zffYo7SJo2)KN)m{0C%F;-86hVcQES3QHuc+2A5Q2OnDXCZI&xxLsQ4+iVet zXVQdUYo@TDnn&RB|LEFjW!^-)U0v@h=#WvJh2d{v5RRE|QX+}i0g$ZpZih(-_MO^t zC^~m(VgD5Z4_}rfJV>hxt@gI)fG=5zSZmt{OenMqL-+>_b&V(m;G%IoHOQyOEG@@> zI}q=d$MD0;Onac3+D@{GXUV(VG`wP^NWfai>}wE z0R%y#pEx{@(eT$q<-;R73>f&nJZzw3B%t{>Sd4rePAwfi@&#?4>gMc!&?F`XW0#=S zwRX!Eu9fbJIfCfke|6mP!HY}Q9ylt{fR$g>ckDW|39WN$D{r|>l(GM!{2t1-(6_v# z&aKh)Tn`Ks>UNr0mYrR49iw~xt-w6!yXZ@a#2of4Et_6_j^Mi3LC?apd5b80S@3#j zI+0FsyCPEy(Nc+`n}<)fmk-IIVv;wADBYHOiad{{?H$UZM|?PeK!tU((){)%hD347 z%^yZ&Cka#Q2HB{a*m9`aF0NY3?9BjT-_cQpQ>gFiu3my`^>R%ej2pdwonSA^hPL+x zr^r0(9>cq}Gd@>UFK}aB7(Uc+^fw;VP7bhi<+&w~X;%T?Dvpbd%rbz(6bpnO;7+iy z|1ul&5^+L;E^^#t>Lo?7O~H)Zu*67&G=Ri4Eb(=s)$|ccD7B%+1g;GT00>)I+plp+ z(-_rFbv<-rHvN61(_Ip^kTh%)C0*VQE+9z?UpiC^d(k+*wKxt^CZ_0g4F%9d2b`y8 z1mQWad*FXv>KEWjB|PCBL(s~L{M9Ww^Rz!LV+WqL=8`AFr7Ak*xfxJ?XjfE;49Q>< zfO5FF)59$GK@-B#?p zt2RITYFCS69qU{6zy55_#=SlW{KajZgN_R^xAe~tudS6`j0wFV3vvCy>Ai3K0?Xee zhCZKz8d<1u>4~7Cuxh&uiWq)aW#hfBv8p(Z2H$QW6_m;5bMm>kdj3q99(#H9YLuA> zrXy8g#yhLHHOVa8)jr0|*5=>a+n5~uP!-cxTHgqKn)G`PkF~vcTPZOnG<9ehf#)2h zZS%X{R+0C!bv85_W8u{3B|7XfS*s)K2t3~-nK_@*!0>GQStI*dJ$yi%KW`c4=(!fo zN$;!w5e$3}>ii0@ZvQ!+PBSJEz7~}`J>Ig4fu#KXZ|LP;B@KKG0}}%f5=)}9+!dJ% zW5SV1Cug+VG4xrngWX{@7KHWTJK)7V^xd6E{7VZ|_oGf0OEKw}lRoR1#|)T`ew4w% zZ^PrmgtA~SCp?&|2Y(yRfW7v$S%im8q2v0qi8b#xVUVFfY&aQ$An2MFZkw9r8V`Zy2f)rtO4SegFLH-3+sf-P03_6r-i@djssL zhjU_K{A5*)+)gtoZO%M1p!l`#KMWVb3s6)r71Tp|g z(FcDeN$wYLHo9&dB6y#61bOweV0eMCFc_d3qu*yCDhIkQI@u_-Wg%)3Gs4izDEbwy z$WR{p!>pAl-mGk^VKwhG*ZW5a0W*)Sdoc1|m--pDTJ9}>cjx$K-w)b|mAo1^U3zEa z5C0&6X6DIxH%J=n{_Q>>6iN@~9;ojj3zFOhu)8g8TL`1#6YvCwiJpo+xczdn=i8JJ zn3_PSZU+4I4ADyRphPP#Ojwh(nRSREYuDoI zGitt61MFN;WHM>7YO?~WxzR!_qjPDG5tvgT8%3YpV~a}flmx~2MteZD3_2+?OkRyN zb>E!{mXIvgNIlX^b>PnnoJH6~apd_|`7AcWJ%_}nDR#0+z&=!^nt>Oy*swac!W-FR zUX7B{wYYg?jcTahU83#w&|$80qgg3omDTQFZyB@NI7!pkOr`&oZ+2tm0lGvr`>wPe zOiWc%srin4(QAc-%>>1JwJ5xL$cWB`!V7h^|3XlGIuh*5yjm6qqVd(&4QO#MB%m~} zcSDVBq68=$zQ#9MNxjYIOm7bG~Jh!z4rho@)tOouc0Mn~4eHaO_uE3+X`3<{F1w%7P43!nc3x?fzj-CsDuPe2{-r>a_6tO zU<&t|G>x|Dgf(#6k}xc;Ly$j@I7gjl8%$(jUVqo=u9rWY?cgif=#*D`re!zb52{Px zIC!rO8k)L1CZXEBNLKiY%bPa__<>7WqXY}j;s4w{G(cy9-VPo-`finCelO^5&IAC0 zr}%;gyke41IB#CQmm_uwT+VRca9_mpmXD|vW4$?dwaKQ2_|(pDVII?RYq}11h$WUU zGHF%Bf=<-S9vi%0o!mcAtTxs?>C!$HZVDRJI+6a zv$0OB^Ko-lLQwcR&;N)IJR)r9F8Frz<&xF=0b3=g*1=>G3N-i{dasV!P}VVH+tOgX z|LM9{*k#ma-iEF)=;vi$25#l{lDp=E7_D@fYE**w6i2bN+T1r>tsScxebZrzAYESO zJ8AIw004WCki5$oeSMFu{`XD&5*;m9^c^fY(9{q<5)yYg-0i7GJ1RDiq z9(`H_cf6ZKI=l2n6l;jPi0F}|iooXfj@Nvwq?1OiLQffflqqY+6M34j7QvC2ee z0Z}@#Y6GVkcS;`#()kFi$0`O?K#k&ocjq*{8D4OHX+S!zYnC@_q_S{T36GToCsUSn zHPn73hkupNexaCdrD5KA115~r&6(yNa>0}#m+;F=EWv5%_hZ7zHuQsOhfPGlCrz8=Md=fN zO5TSMlaSk>csHNQFuhq6(q7!=%_7sP#BYV7!3)H~cjA+f5gh|h+e*Ra4vG-{axe=$ zedCOPpa>8p>=brvQnMY>3>z!(vV=^H)Jb8C_G9zX z-$P-Tawdp9rntyo@)m!Vxc_b07Ow0kGX0eb4iAz>>6Xi=%p+}}hM-Fej`WV}t&n!m zt@D69y?4n~DT;4m>>vCxmc~8yEOc(J+1I19Tbq8q!_~TPxXwY$;(WW&1RZ<$MBGm= zYfL5vy|f>EYH#nh(P+WRZH*j3AcAL6!iiw%J(77WW#_Wy!MA}n80n%XbkfIZGF~W{ zz+{P`7o&9;T4UqRc~j*BP<#jNolvj@f_x`758>;i>JfX7V@M1MOzo+pQvMX#oK({0 zoo4Pck)RZ4K^8l0Xqd96001H9(WU$xG4?AJ>nl!jljbYLI$AG-lpUTSAxVl4i^xxS zFnpl9dm!>|A%SDFDD{D!OpT$|l-qk(a}H(TLTw#L9~-~}79+nNQw~6~3`qwp6$XNtEPRO{Fv#9>LdNQRd`Ny!(6K?JPTmB$EWsoY12O92S@O!>PoS z=gTYWhILFSoC4Q24KE{AIHGoV3r}Y_GS51tz`{D#lYXG-bzXn}fU_z8n?;I3H@`s) z-?Hfaswlw2gFzJ^3xO@{L|~6|-U;6o#AarN=zLywlyAr$AES0;PIKO+w0qujQA{HP z{hCe+Bm_werxJB@98|a+G)gE4Q~GT~!IPIRUZ>B{qF&2&ZHHa^MNXBTTaG2s934Eh z7*KXCvL(@%jQW~iOjfs)sbwcnKIHqI!+TKo=ahUFI^x`%)wzh8#T^6bcg4yY$F_%C zWcDgt0tp^Teh#6@@^_3Ek=6~JdfR~As=(&El$t}VwQp#HNTrQAE9*0xXf?StO!rFqMm@5*jxk~jP~ z*MyS0)$9l``uCm6*ME+1Eb>=L$AMeH4czQ!36?o_*Q!eW;XXChGoJK1)o(cFOmm%1dl{`{_xVA|nI z!N@f8q?p0s*WJF^ki}9%Yxu6W?8i3AizpSPM?&qD)M(c3o2Y#U-$DW|1B>!5b44pE zKmG0`00;E9eXUA$Lrq`me6dzt1xYd4!w{Tu)G)q}=% zm^F5=t@PbRTdc!u@Ho;VGe20yF50DSX9fe(DsT57%vdY={Xq{^oHVfq)&;8deRxaK zGp_@t-N){#Qq6%Opie{gedVj3t0?d}(zmvX&Q;frEiCHcro;u(rcUku&^$L$u9QTU z{&t{BZ*0u(IrPBxVj^h8nLO)M<2Ks(+|N(D9>@g z=`f#JTjL=82gCNH`Rp#`_?QIzmu#e3NUx&J#^ddA-u9G!Q(JoSqB0EgguQhgGU!mp z>wUi}Yk}{#dg0#6B1pTfvY1jS+x0G~Sl|PS!@&)J2b($YM6`PgJL8fgurDO!X=%#i zyk|g%o?wKQq;aK=7ip2EM zdVD4$r~W-|oCI_GWuCMXT6;O;&h?@%c z9CUJX+5BNp5c7feD-rM0)0P z_J1FI;O4^e!T>;BEZnCd#D5sXSw&I=WNnPj?>|6fETtk30C@fP@aG={0KET4`JVy+ zu1o;Hxd8yclLi1_I%Ksg^Zl2AG?JAR1N{8Y^14bB|6^brq%@rY064V&IS3#l3+q1; z%0*gU9O@hr0+NgBh~>xhzaju>F=17Y^~)~LMpJdo>o2|=9qSush3tQ-UNpW&7IvT_ zQb3R*6bS5LsT~=2@oFlfFyL0#fTL}<;vR|eF9t#&DCm4)@0SybfY2{yWT!K6Xz&F! z@}=B{7d`9g7vFBd<7z+x_^;V`qwnD|<*Dt`;ce#{{uSPxV^4pH!Z`~zoap}_{$ELO z&^-C^{T{fiw%0LYE_iQkQ{nK?c~t+hfy?Oar=9zMh5jylJuL2-w;PO>IM^t$C5!e` z|NHRghW{@IuDboEp960~j0{_;Fk57`sDuMB%mxRBikuZWt9@K7hzP9{expS$F4S?p z&uD(FyE<}9diA5zoX>IRWxxIVig42pC#}*2zoYJT+@{-6E`~)|SBKJ2d^uB2l%VTI zQzEuW?2u^k7eQmrIR$q|48N0CQ-|Wz{3hv`CI;#WlIL~kvmayi&4S*;ys;XOophGM z)|_sK`;S&kz1gJi(+Z;JSox`tzl6RVIU! z$?ATHpvPweQloCc9B87%K}WCk&PVxmxS&%iC}8H5(9cs)^F_Nf*J+V@1h46;Y{3n0 zBim=uC3%bp3zC~c?o=8ek_sOlA_d6`3Jvf_Q9_o;JA@~bc<$v28Affln~8q=B77gL zX7t^A@VlM$&i1}DCb%QQ-|ia~mz)~jdr{=N31#HjLwu1>d@`nxP~f@n-Rsg+1cyE2 zPl}`a1(-4cGEc%Qh=#$CK*PdkKqvEyr=Sav5#o8S;%9!iUHtQFxB0=W{yc=5=5stM zfAPNDK>El$1=IW7VEntwfAbUcpuq2X^qQQU93_Huz*c3HV*&{VWP$;R24RPXn+L(v zZ5l?Gz(6O!gHR`vXt7Yq+_B-pRd@V7+5h<%nbGZWzL_rV{4VwT4z$Iz{I3UZn0h|i zWf%P3wp#w)lIC{HWDd!u7$t?o$-qPxsz$K=vW&mT#T3^{nvJ`t86sgbv()(3N(y`1 z#c42NZp9dqogNC^CqJP&AStSNo;ryAew4T8&RTiy`dL8|cx$+Mts*{A7FVu6EHuLV zgW;Uq$No^croo1*^PoA$6oZ4Q>B(V*6?rT=XEZr6q8nLf!Ay%|78o+ej#xPCs2W3( zoBIA1rTGN*acIi8^$?P(;}AOF>YIDl%=wJe@jA3J`G*WWl|;vsDuP+|TE9yVNjH~U z_MtK@Hos2FSW~Y>JcFK)P=Xp$Tna}Qr-(}B@cl;;9@f|8#O|k6@rN$b0g}(6Qxk`wo^P2F zi2XO(l4e0pe;EPdD$s~M!=I=fmhlw?y+_V=etc6qAFAvHH2v@gXYbfxMT$uFHD{YRN%%y4*&O#1t=GqZ<4cl*F6s|JJ|m<);x7;DPEE1yv!VmzsWGDIc4lc7Ok~`M@+qvIpFF1P7?>(RMpAK3(g73TJ&F;AZx0Hfk zNeyAQ!Fh6HLx)A&yZKVYOj6EVU?%>mROoQ%)TaHQwV=iDX+q#Y5cBXPmPiR?Q54K$ zNDHMf%F3D&!p?y;O27NjF$jH+87RFKw}jyr99ygWJKepzpRcN(cV|Vuf z&au8;?b?~Sxnh5Kvhz}5QKrt8Y)-rNiK3ly{?BDDokLI4Kn5p;wYRsxs<6$}$myU`rX zxiG0P7!g!-9)t~!eaqJN?`Okl9H=rX8r2=@RM|AKdKNHPd;5>YBhi~x{-!wJxZbzs94 z&}4!)P=wC++IoL?pul=d1@A8hj0D#@%y;Z_efMEFK=ZWK)xqct#-V8^92D|`luE$^ zrTih`gpm88!HF)AoRl7bc~EnrI&LSlp%jHRQ)He9G7KJ zcU?h>h`uqhK-&ugZSOKk1DVw#V;7;(US%bYK3*bOs*ohKBm^UWIe++jL@Hu!wxcv!VA5!On;hJ~IP=UCwkL!;gn$r)>RC}RBTW*VfJ2a=SoVrU z-Mp9QN`oiCf(RJ{0>*lWgwqoRClrF@V${h9g9%>hx%JF5PMdUfv2}mK#Yu3X`G6N(5=Cyp|P(9mwKtVzQ0jyaRQ;dR&z+8Zw02kn9CK8lD)K4J* zNI zNQA;*4lzVYq6G;_GD8p)m5?MM5Q2395lWUo3(x|glaD=q@+)utA5#E$-6!7KwcVxB z0+UAL-@x4SdE2&5Sc^1*4sG(36jD$EC$}1)rlc2P#8uU9HeAMighuBkPs{YVa5cD1O=^z)Ljj&Q&8-}nCj%P zIzv#&1Pf6Jf?+ZTATUG+0g3d0`}*4YWXRqFz)#-w5vA=ejqe3o1m5`0pX{Mkiz+@6 zPLN@VCb$Tz1@;1xLWlxIsHVe>5=p=dz$Ca3q8pHc5)op7DH+Uzs*!a>5^5i<6CujX zED!?GN5o;#`1-|{KXTIdDFs}~ia`aCLkf{$5WO~CcBuU&C2AvtSZ|mvkwAzfm$D^5 z4LxX39p)hbD1#&=3jjt6j6fCUkYIDFlv27)Zr#4Va`>Fodh67|VNoK&LPnzr34qJ$ zrDPdNpaU=z1`Hw)G9?s20Z9xZ$Po&&V1RIlLWIP$%$^_wh=KuCLngo>qhFu0SVEsH zMcY*xEih>`z70xNH=WetjZI)_d5mO75eyOrNN^bv1O%v*M2QK(fCW)xkw8-Hz-1)} z1OyNWOhlvr26mDq5;F*btXno|Iz>jHH$woNKyZ*WC`FKFh6wcp-H3fMbB1TF_N#}D zP-9AoB~k}SQ=?57CYqKBxsX8#W&ng*u_0km%a3M3gb~XOh^c0lU_b{*AR(s=l3-*N z3={xU)xU$3gnffcN_OMs0Mc4u(rAH6qw(z!Iqd2^gROLH_~$)yKnzR9B6)925uwx@ zBPgpyc&|wh2{5CiVCjfjI50y%b?5G_W?e#^#N`o`QR5OJ%#59hHyq5`w!YTy+BeUP zI4n}&4CTD*YOI{TY25s*V5`PXLO7%(A^@8Nm}toeBv@4W3>8bu{z?FV+)1(iT~fU# z29hx-H`o;Dtfo^`mOn(8NwwzSis3-Q>OqAs@RzTBPO%~ z5=0neQG1kV1{eE8E=8aeHQ|v0QU{sBkPS7Cu1o78I(Q(lUa3(>bYNOOM`B1yDXF+y zMD`=GhHDSZ1E~cjjTV?R8sA=E!r0h(V3Cj7w$kriWReDRvLHGEiV#-1BtbHWs@)+$ z2q7o{vWiim{##9W6SSIW0XjOW2sl|a!4XDUh@?vg#u8De0~=%BI$b?1*46092lq$P z!g1hJ25bpf0AWU}YIOh*g3QF)j~NtUlmp3^OXu(6@7W`~7_NlFCEJaag(j?xPg1h_(U%ZgS4pryaixTu9M zO4Om^0_)gLs-8q7Md6SRSZ(73fQbSm(Jj|4jHo{X0F;>`HbZ7ia2pk43V!t&x8K^0dTq48q|x|xn0AvBIUj977&BB5GKx_T+#$IYE(bCr zL6${JR0tq}(aa!(!IGdNRwa}I7>#ma<}@aW4wAvBJ%hEEB|=-YlIBW0IG?P=O1{|} z=H-9&I-c_rKY5$EnV#|VOXgSn`6E*FyCb0^)Fs5S%n~_Ji(COEf?mZ)MFvY4p{nzh z04D(lvNyMX0{NPvY@`({lYfCb4uQlBC)-Dy{R`5JrN6FzWQWK8D! z_Z?#=o;f{ot+SoG6C4B;GS>W_0aXBof0G2>^C}=Gv_lyh#KRBQ=hI=Rhen&OPfAPEax%}D3t7QQ#a+taWpzK9sHSCKagaP1Oz$nKUQgLu7fg#`nfI-f=3P%a^ zh{$0aOVJoWL1=)mP-u*>?1pR>3jjFywx@FI!(Vx`H~a8WMu(yV1t1B4L8=WV zBn&Qq9)J=umx&}ej8*m|830x3yQ*wT1VjT+KmdtwMJ)mW07+w2zpF!440-q{MKK`y ze~>G$*PAL8LZPz4oL!A;Leqck>;Jw-KKDQL25?J32>@sq`Y4&57l09nFer)UfUI_~ zf<%mjyU-a>AS7B{Fp>oV5@VJoGgFn7C8~gL>O!tmhKTXD zbH9vy>+-)sbdjTM7EDK!Y86aVsH6m_Sgk4=5>$nCz=EQrWdm$jfN&L|N&?a;0iG-v zB2C3nXQHa$2_u#tP6~h~6_eOyc#QHcP&I-6^Ie|wuU_akee`*J^!YDr0ZrpJMN17f zzT=2h=v*N=LfH#TAzYG!$dWu1#f8zm9#KQ)IaG>klu*Kv!%;%;QHqe|5>Y}JBQ%zf za?m~C4jW4_hgga%2QidD6u2)h@ejTG-QTnf@Wr>hIDP5OFS0A&^uqKnAG_*}|NZNJ z{@U07G6CS4*FWE`d;Rly-Rpk&8~*>DZ+H%G_={(A&l`T;_P*iSY4;nSMF80QhG!T6 z?0#b{-Rybe&)oF0`(O9un|+=0cRa>w#-v2@nJ_MdvLH$(VgcmQkOv|U%fHVAEQoPo zs(+`(MR1r0d`|g{C>zo^qsjmb3t(J;+63|@p}8~;K%OW12$eY$1K>Fn15`#Cq%w{u z7{BE);lKX-C*JJ)yX5kxeZyY#_RF4j({tZ)+0S-wx$Nh@rBcpceC)s7;=cBuf8b|t zJutEGC}44DbYYMzt5^wf;i4DG!!>xQl;9BLD0Raq4DOrYx+LvCC={V^s1IHg{Q!9sqEk3orbp8}ylv`~?Sq zYu@xb-*96)`7yuobw7W?<9|;8aN^@%aEnc!$3O03-EeW#4jr?Xl`wQeczU?vdKScsV;c`$zQ%`dPQJ?i=Kvddq?pFQX1vnD@z z-t%+DgagUihcOq36HigO8s)?S8KxznlBW z+dlSaKK#5lwZN#+0+a7`E`0eX`i+BQ|4*O#^0{-5)o~u%BM(PtF&rP{4vac#(%D+9 zZP_YnC5UNf*q(?joKCyGGhFiiPt#Vm*vfjBrqeC4I_Xf2ZRf{qiT? zasREo28wgfW6U$66eL1q3IOC7T6zr&?pE)s#_H9YolJr+iv_NPW#|G!fUFjD5p_Uw z>BFOrvO)z#8l!^)E=C!GfNu4)`O(+^VPE!Kf4gh``Kq%P^ZD(znD>`{@S}0@!yb4} zl4g>dSOn*0NArl1AfcbyG$24K0g2++B?PH3cDXOJqhL0&#B`~Bwy?yI3V_rdx2_pG zOnRm3`;{ri`l@z}q<^RNEZft!1sYY%%%RgND$%m%<00Zb|SSGDEAD%wg27_@9= zForQw$Ye_u(`B!0Qdlq{SrunfT0USz5>TqIfPqVKQdZ@4I1oJ%B4Q{-`I}}k-t^vw z9r5Med+&Aq#{90UUKUp$7)NNTX^+OaI0=BVk4XGF6Ajst2=FUHXal{{826U+|3A{yb*;rm>N( z>JKjF-8DBkbslr0RWP&*r2HMiU4+=&xv*L;v$D2*-|IM3U&m-3Qbk_?X zm0$Dxr@a51Gj^Q5eSKS$6ro8sIe(=fB7kD(y`Z`eMm8gHc$3Faj6}86rvas2wE+hC)Vz zN~%3V41`98tE4=pvhevgyN=&?>8D@5<3HW`$A9>~_sn}X_OIT5b+aGEOq)h=^|gQ! zkOx7t#jwXlXbc&=D9IuAQ^{#embR|uG-kMQ;><>sB*IeZpd5)u9=#$@X`z&YxwAd< zef<}&+_Q1i);pdL;LG2bD5*)7aHQEd#^_-=c>@TM6ab;k>n@}=3#M*Yx*abUYvgV9?$V>Z~WA@ zlW*7m&~HEG+0n%`#Dg8V=>S$7v1VXMqA}Lx9!Okj`sO0IFTK9_I<%=Yxe zrO^VD?*#XH?X~>y6HmCs zFVy{Bb9MUQlTPyc{Mprv3A?|0{E7KT-*R30-;dmJt0(p!dfDf@jT`rDz2>TYr`>qf zjrZ|`2hYVM{*(LGGjZ&(YoEOQ20P*OEq44d$8mD(N;zqLHJxzWRy%9E>%{dbxAelc z9&*B9L`Q@GEC8FMcFk#N4O@bp6cI8U+>qujjx%8Ao`{kiIf|(!^J9J0R3wvP>Q0=p zqt~>b#g^f~wp)5Hzy9_s?ROq_S`0%uu$T#Orcog&AzW4C5(b6S15y-J4|$0qvw;y+ z>49z#YC|T?t1c-?nt~w^B&##YA)VL&$XfkzY`XcZbREj0kYebnOxpuG7ve`VVR zC+#}*q(?s)!*tzXgHh%kj|^E#L_nysKLI2|Dgigc$N*CZhXV{mpo+MMKqgFMd4xuR zg+pOrGK2#e356Nb8pbYR7%{sl{qSVx{bIiM4cE*3FD@iQSb6;aQdwY<@vo>1Jf1V(x&O&sKRM>oOD>!4IQul*wi7m0#<7_1(kjbQ3J(dY&-ks)>o zNP^Ye@?utRWkV+Dq4i^tvaPf9w*8jYppdl>qEH#dXT_O^kWylu5Uv>q!BMIcpF5D~ zhB%~5S^_Luo?scYFcou}5)eYJXLw_E5*7npwJAwRs*|r0Bt(fo(XE$qeBa)+)uR?k z*ZiH*6<0(`8B_+=))LGCO5tX9C@H^`iX)}Y-C!jW8EHA<44J`1lr0`6))TS2Da;Pp zswBxbLY$_QP*m2Y=yn_%+;iR3rpKwgu^`(PG|rIsLlwZ3Xxh>RI9%t zD63|PLN&svrZ4sRr4WZqmragkrJ{QIp{n9xdFQL$B@|5bn*A|QF*KXS-JmuFN+MpN}%mBiTQDzziT0dNF}jlgvycQn+d{#DhkNzH}T&g2Z87&1Qsf zB7t&7LSXqxMV1#2BN77LxVAdwS})r*|0f54uMJARjzGzB&I>R~o*hiITAGQ15NU}B zKV;l2fDuHP!WdvJm07Mru5v+E8+Bl0sD4SpMTkL@6-Y_To*)VcDKh42bpl)gV}a4aRV+aWh$%^l z9jO#PWB~y|mO2YsC{0orS7E*qDLTJ?`*}KT$Afsk+picp zbaZvHvent&&V9~darJ%oNJv)v(>cG=~{G&JZ!`xg@^3IQ5#EZ^w~!>rSbf?lfRMwiuOqnVQ*n>k24ZEpC6!UP zXmS604?VT+xr$DOEc3uvRPS=6EMyT7Q(!0wiA>9lT`GZ8&C=@b996R$hFbc`a3-Z{ z%DU{VKoSrUDl}8LlgnEJvjiXEwM*h(1d(t2bKdVUPpBv4DYi{)PF{VrcX@Z9qzJu)Jg)huUz=wM5MhLM?&d{m|22*X|G1PHlG6Wh))qD<# zq^NJ8j;q2A^`5$XLL?fN7K_Utl^d$}b4g|_3J7^fF2B`g)5S1$!{D~S724#N`-OsA z3cD~tVIdBOM!Ay7LJ&={?8J3Ooh5hCJJedBI<1+CLJR~QFmq%#Qmh|Am|$=&q+qAv zNN3-tVZ`jv>-znVypqqo;Hh8t9cBtI19L>ZZ#bq-}CNXm;WDFVng(yE#YB0PuD zP%vcBW}uk7qbmw(zDNW;BJwgiH}+iyViHq_ucTFW+>#Twq-nQ3rt7ELdO0gjyUY2+ z-Btox6WAq3KE7c8j@|or-eJsF{mf^+@+Exoi(m4OeCmq%5B=gvo15cs^%~OuIbvIS z_l~V`ec;ukgn;G*;2DFFD|pyX|rKG;&1_>jB5+uL2M1ty0L_kQiQRsj6N z6HoZMjrXHZK3V7f;T3k~umA1(jaOZLr;Y1xcz8K*@W(#-rgxpZ?f8?|PP+4{)B8Q- zd_V2@@hv9z-Iz>9RzpHrufI}0`mpB#oQS5K8nib5UJyY(`nHJ3?q{z zRuyq|ilne!n37fR31PC1Fk#to07%IBkl|?%>O%w*WO5UbxZ=Tpv1Z9x=Qe6t1;L~g zReCE45k*=Q_+l7s)4OfoY~AFT4U&d%j-}2i)Ts_%#ywSp%9rT~8USMy$>_C%fvoD@ zC`^P0NTUPG3;>OkRO-+K6v&Zi<_0Afee}Yy6b?DQp~vKYk9mUr=Fk2o@B5UeD`Ko# zS!Dr~+}|{HvN=nDJD9jsh10V5PtXEgA{1KMA9wX()#d=QRwn9w-@t07SIbUb1nhhO8j%sG>VS%B zheyw-Ai4n-a3*88WT03`(Sb44jS(7((#sz43jqMnJMZg9og_6RFG(<9vz&B?$9|w%Ly#7G>|TH*D-a_Y2ov{!>@&`kemeNU^Iv*u+Fbn5Wp8`eS!>50z5ac7KON^h>E8JMyPbgJjxcqQ zCM2#O;0HFLf4HvL?hV&$=wc{tlQ4+Tkw)4?j8Fgx)_0h8k+$|JYO$cx=rAKu!h&5` z9k@u8JH|RHOXLI+^xE72BSeu777@v4P$-dbfu_O6OpRJ#j>Dzs`pV)s>3E^ zkYbGyYITBKc6C}2Nd^pQEMB~17T)~8$bFyk^jdaOnZ!_l`35mR0HoegM69eDs^XZT zDq*qi^2=br`Wx!l9@wZFSO$@Df>6Ln3=426p0@19S&og=9uvwCh@~)F6;?|Cq5xc$ zf?!d{vXepq3$j=pcEvbHDY(^)NgjON!qSMnQGA0@9G!y z&RlzTYuk|^kfPW5AhE8f>X`JQ_g4xK7Enp_V2{K+V1^vs>nF z5F(I5l9>twBZZ-)7+h8)yXDepApp%6d8MQB1Q zc2ndd7P0H7FI{!%Z@lL3-t-&4`|3{)XFPoC=g&H0?axm-=7_ic@Y(PD($|%IK6W7i z@}l$J73~h!0+ZV$C;hj}kKg;5T@RTZy#7&Ff8dIHopA2)YY%?Xqx>#++@|B!y*nbM zyeDASY=T$r0zZD$0taWb!4b^KtV;kpW7n}WaCI$-s@7?Qx&mm@iGU-D#e%hhZb&xk zRF7T7gcPK+4u#YWg%XKKNLHr+jAn>HF|(w=0PCubLfvxdH%;4G3czW`IzR%i6%V2c zi-2Y@2eM#E(!#0DT-C~>`06)XH}~+NAXPysVCbBY4aJ-?)e~-XmK3!cVkx%)6Ceu+ z28#q2Q0k3_Rd!vL42U3;6G4!w83Ce>{a6ZfsJQh*F}iEG#lZ8mui|x=y{E2o&cIj# zz10chsX@uj+iFaPsG}F42<7Sq>;>>K z!4U`~i(9zKtNt_PilVSQCR9by+8IHnn*yN%fu5GtCtC&`r~zHAHPk5_EnR~ZFebSh zY4Q*)C50{1Xfn}Y7Vb>q(r!*slHd!5;a10mJwyE@5P1;tJ2+(7P_LWDd6=uzMdkVW0vFFfoy`u&gm z5uba(&)k%O__cdJG3wH}d*sN$G{{S?Z7zVL%DP%r<O#W zGb>BYc+De(A zGxfcRUSP>0=C;ufP`TDNW}i_9!P^Raj9@BM>6f+LT!Fktw~Uf_xYp-)|txNlE%^Nc_;n;0-%S@FpfDGBOU zk?Nj=aG>)M;Um~tb-dUhkq9n?L%jkuqd<02z??!&!3Jb$shkxcOm6c3pS^dFx-~1S zJLi1XyZ3iFm%34*xLKf4N8$w}LWm%PMoc7Lf`}2@MmrXY+8AvS?T~1zvFSEOqfLb9 z5a}|}&|++D#T!vj0Z|k|6dDvtZdFB9QMYrx@7w!**E47T@vMDHQdOr+Iz5t%oIOU3 z!KKdm_V@0!*Lv3T%=w#9NU=1H2sq(r1w%-pL=Q#<_JTAllcPZa*dz>5Hd_lK9g527 zRqgeWW%KRCv=J8X1+?71`1R}l^`G&jU;0RQV7n|`S{z$2L7vV`ZWfklgQ>k*3%ec650rWw3-d4xh!&9%7}Td*RTDHAK(+8^Ob(p z*FTF(4lPKZ0Y#0Rre3odgJ>#E00^+xG2H9PVh6}lj{9j{-7&x|FbQN}mz-Sl93Yo~ z(XlL5$!M;^=zea<9(gJ5BG?1YEF8V6lH75=3T+O=qM<1v*a$5^w9>nj$7ys}NZ3up z?o?z$!0e^hEFDcq)zL4rHV|%<1?Q%>rU$^hJE94uA)hovlb8|GsHGZ=ML{y15w^_* zQkWJr%vM!axn>KLWekf^knYt_t20M|)}fftAiK9!L;0uB8vuOM6P|zH?|CBnHZkYo zxf4ATjz%*x%w|>P(_6O`WAMV}Gm)NFCRB5NYmR_hh%?vw5NbTYpi(7b9m-S_t% zf&GJPm(Te4C-KRj@cGtqaLqeD_~x(vx!?QM=RNfWzIoF8Ltp$kf93nW>TkXN^Vw%V_OCw@4}BQsyXBKV`7qYEy>+X8 zqDk)2ua?16sY@UPGMns2OkJ8vni1P9Q2pk*Q#zt)+Q^X6jTub$AfCMu(MX`(~gA~_`;jZ7IW%tR=hObbbSxU*`? z0<|&AsU}a1?h~MdIGQU3KcnPEB$%B*K=U3Z%;;JUFZNzVP=T0bE*(w*=3>hY2wPz? zy*1lzxKrH_l@Mpwo$97-Fw8{SNk-{oO5`e6fub=1f?HC6TGd>1B!s?XT=&!87cYO- z3-oP||N4(Arh18<;kE7(oY6oA(t%deS0<$hM>&8Z5m_IitZL)Y6w;BV+phB^AfX!g z(oBuFSnHuKNun|wOdtqVmu-}1;|7&P36;9b*X9A}qdXx>Wd@|A!n|x5bCXwSL*m@) zn$i<&ExKs1fnazB^FVEXkJM!Xn8BQcoBPqpU7WG+=!>V-<@r~yd2~MdS^u+6p0T~+ z5C8brzw1YT<>&sv6Q1+X*F53TXTJ3ZzWH76x^x0wDop;fdi|yA{_TTq`QWXuyy4A% z^2Db-`_uNH{ItJ~gB5V=oyZ$*$++Q0t&cWD!bVP1Ai`@bjAldFC`j_CdKoqyMl^=B zshP#p>QWUl+`OAvfCCCX5kTt^H5VW_mrkSyJ!sfv`jpKvQwSv$!NOLQf??b!!PA-~pELec8!@9{eZM&{3O3{Wrjf6{vQqaesw8LQLmBa{eEBPH=8r%7 znvdEjw>g&6-gJ7?yN|HnvaPKedAq<96RBRdP$yZP?cMCVCe|IOj(4DV~|H$zab z-KlIx4W{a8J2*9H5pc95w!Ovq<@EkV56%56kFGdpUf6Flt}m#;S4Tka*$+rI2^ z5iPy?>bF?ubK)&GbW1BTsv(goK?OZDAt+4@2Z@w0yx5D$h+=+CUDTLElm*Ai4wecN z-pV^DX*Rf7h=mlJ8;Ro-CM=FExyP*l;0>?&Z}`N|zBiBJVDgsC`HmIZu_t4-c3WCQ zu(7P&OjyyU=;NthD4lYG2sGH*a;K`mjTBZ@68Y9{_0vj?lE8F$Ntsl?Zx_?pZogXedQP2_>WFLbnChE zU;Wx2y#1Fhl>?UwlmDcz)l+}?aXQL>efA7L?(<&qg?Pfpt@{T~n7{pj^j)_vT>8XS zH`!>Brb0P35XE%!m?I4DOhjY-kn=ToAyZzB?&|cO-P>|>p+l3iJ zm?bEPvNoW!dgcO(Wj0|(aaTmaju!9+h?W6oGCRER;v5q!4n`MFP7vA@#+{mw<2H^$ zR}_#$gh;1@yH12IEP-ONMHdtkq8rtgGLqG&0tdn@iS2Am;}k;|xBxlZ9rr4vn$s7U zBXgd3ySZT88vQN1fW`IUO&mS0D)$6A^MS<*Z|WP(72 zB%vmRAj!bgusf=2Wvj}KbY%^>nRHY22}Gk|TsFqlLL?G70nM3;?P7FT*FW=x_gcd* zddfe%@OvDm)=O?9c_KmU)a;B7a;)M=R_z}3QM2eSg?b}RhZT~O!|Fs4j8fz>F;{yn zCNjqu%Jnb;gruN}&Tgt^Qw;hrFHN&*nE`6_<03C*_kL`>lBL<6rQw z*T3nT6Kli(zAAjfZKYvLmd#Nz_|L=9?OJDWzo11R? z$;UkN$)EnTFSr)_4?fVJ-2}h#O*4;Hk?R2#sg z;7$r^5s)KjMhkUUmEmGEv97to(4$U*cFqd6W5q%Q z`#t$;N_lR9mIVh@NWmyxB^?gx6r(UDG8vJLKr}=nv06(+aFYo1#@3u?S5OzRI?P)E z;3vQAntN^V|LV&>X8dpN+l87_T)G)fqxf#<-9Xh7PJuP5k&OnrI90CQro!a<&P$?X zvZd2xRN)ec=ytm9-aQ@mh=6g^HZ>;x7r7zFvR7(JIdVI!!7y66l;LQc79(3xEJpqb zCIEeWmK3opJB(*F#HUsHNxMo8>!CDuXEn+~=pGAbCYB9jJ5%ep*X?-vy0>_K>8B9@ zrYYtYJ<#V$2|`Ww+#yc2K5DAdNB@j=WJEfLggEJ#3Y-yU9hjgb^=i8XNQWh~O5ahi zsWWL1osJ+|%j6WAXUcg|@vCWfnW=3DbG^)q7!wfNuq-ReH`GfBh0)~e5M45!AsbEX zNbt-ERdM3dG1N2#1UtZxdlIx6b7o(~gcC35I~DQ$33Z`NM{Zc=YbA0{1R34#rEhU@ zeBt`iGZ-~hDV@_i9;#4G6@*n(KE;CQhJs`i{e@W=*@{|JRN7@k^cm(N`YV3=`+5Dd zzRBP6`j_yZJn5cs&UgLVcktUk>pQH>g=S(m(^yZMBFP)1dCI7Q1^vpvysyX)a7SBA~<$(If5T$+1D0rW!hCG8!T$xn^BF z_8r(9{S@uXaoOol@1OaEr?=mD+*7XlnK!)Z=eOs7?!8wYte*Yqm)!bh0Jv0?Tq;cd zqrM*f^7qBtuDiC(M0N_i0;=}wuzwE*HorFuy ziQN2L$35n9_4qYKe=pX2)RxU;{=%mV`BvEKAfGlNs z$rdM)v-_fqja)W|+$z9cG8Ho6Hlg=wEx7|T0W3SlY8~j*AflGyhs=iFRB}HH?qMLH zakQd%5oN;2gqDJLqytz49a%VU>GXYyUqfyHx;oYtz&;z9)dlzPfXq938mjXXAeNz- zY%c0WW3yFfKb9UC3r}jAF)3ml z2YY+$eb3M~$X4GK!-6I-6f2{T??=gYv}+2wi*C$FLCryEfnldxn2G}Q+YaO&_L`i? z$exDRP0=j}Ydw7LZ+}mm&wYRAlfL1j*&xCiGM8?3xwI5(cEKeoPWlcv*&H=a4DohYfK%1)9)&`I!D4;J`HHn#u>!0;a27o_blmNiDf7W*l zom6c1Pp@&v1ANSb9tPUp_mxw`Ap1!M6^&uDDtabD7Rbb6simQ!|2N5QYUot6JCx~; zY_O1!I|P{Q?6ifHwIHK9MkPw$^KooxusOI)4kGt@u|NO-AOJ~3K~xZd5E)T}n{ojf zs`ToqMoK1_w%y3tMlc{}`SC2L5P}uV0_%|}E7+Px*pQ6mQ1TLFPRPh`273e=nltuS zD{giB$s0kv)n`sU63=|{GnUW&^e=zZcmCu*`t5UHg;zZHOF#AbU;flzyO*o)XW#Vs z@r);2Dp)QRCjSAiw_SIwuKj^O;Z48w``2yW`OfeE@>hJ#nTLP;Wy{;%(c=R*&dL_k z#1(Q40@ZO`7PCNKlc3a*&UZtd=uue66ff9EY2d0ofeLU9s82kgR!het)Mo&= zucGAHulPW`^6x!DX3Lr6ws+1#O`MZ(I;tLP^|Mixh+G8Qq)?Y9&}4?#D&+$^e^b>l zBI||+N^d0xCSTEEyjX@dLFomk!OiG3E@ssctXzj~-i%CAzs>J->& zZXdlECc_beh_Z7>)Tx@il!H6stKk|#h0shKbpSKObYv%v&Ka^D?OtvBv^(&Wj}xwv(EbIUFIdKJ@}Hl{{95Nj}soKBF4X76coMvFq1 zR_jNuf+SmydC4;8(k&fmBswmXWK&w1S}3%(9IP-kbTSjm{xGcNy^4|x!#&gU!tCa{ zyf>>*)?QE@8xo79a17N%z@|FcTpw}qPU?0a8O==_Vin}npr&Y3W1oP= zRRa}TPlquzV&5yoid=E6Q|o3-E3P}Rnhw00R%~qCYt1XHJxvFkS~GGFM6k6L8XZwd zKgy8~l}<@y&&8YESlYHtc6Bc9UMOc7mBxooOqf6c1+zB0YzEkCx&t5=ltwQ}wlvVn zg74Di%0xs{r9a6usjTua5bO+uCfE+ImN75f12Fse_NKlpX_`G54t8-MoaU-QXd`2~;svgbUK zZ@P*7mw#mw8|}x`?ATu+CH5Z75}KN%&_Pd*U5~_BW9$yGDIg~5D6F|XDlyka#fUT* zQF_M^LrF0PsX>c@AcqZ?(E_UpI5+?f*2Jj;=y0#1>NW;8C&0}!ar0gFt>==r-~2_^*l^nOb8& ztoi;l?MdfmWVFix;91w-%%A>>j~?gb@M^-4GBLY(o`bEG6#oz*_8%;M^;(ZoET4L9mqpY#L4-8(^d5S`)MHVV5A>4ECCTjManU>;agD zSsNfC#|t9&yaZk{D$x_!wdr z=jez|L=(~_?Qw*dAgaBl(*SHxZfPpwxtm`?Mu)s?>d z;BubbKf^QYQ`(C|U(qfzqB+3EzEbDHMTt${c(a^v$%RYKNF>aRRz)gW;eE=uHH>`D z7GTz`vUEyRs78UH5lIw>$3TSC9F$10Gr(MN8wqNIFv;eau%3>Ne^_I3X}g&uk};^w{POPcl@uX|X2qnEs1I#naWyEDt(TV7 z*-dAmA6AomxFXM;(dHBqy}eQ&4i{w#s&I8c2g^ z*&Sp9rqvMu{JTZTcuJ)oB<4BOvpc&VXs8%wexCF0_6yTWM+yyPTxcHUMW{GXtAp7D*6Y~P(-KFA5M46#f^j0w- z0((1mO+>wsQ6xC(p6nBriF=d*)d;I=y=Y{VWg%4{hZC;U7d72m1F`pwf9TEkeM$q6 znV{XxKFgj!jnTjuGKp4jTYaV?OK^A1EE0}jHnS`d1&<32SjfmTsA5sUs4N&UB!Lry zq7gDk#L~T_71Twau{^~R#5PYD2Z-2*7k17&e0he^(&`A6`le#%l0x>vV33v^qwr*t zb}-<PhYt=jfIIAl`D~mtnn{m;RM1CFyMl(5Ok*;c3V6}1p2@oIt?k( zgdnF)iR_E9Y=HALy3&aZz=f|{SL$*ym7miOY75N({Z*v zQ9`WdMc#L9C4r(B5=QGIQi4%mLJ}yIS`^zy4bkKQLoY}OM1g2vlL4-v2FIDh@a3n# zQ)hr^!3}}i&w+3Li1nd&&T;4a7cIAJ0(ULI44|No!hRV04jisC9>nIoR)Ozz62eG8 z)6hT+K87bCEAiemFnf4LmOzw}fMfD)Tb?3ZSt||HC=w#Z9f}bIXV@VkD9s(KwSWg0 zjr_Miely?o_TS&;c4nU^F9k4xyQ{ObP;Md;+<8JUg7@mctc+qxBM;AXL#5}rAu7*M zwR;w?h1Sk|5(}&ec9&aP)3VKF_uj7mp_j*3{14aZqs5N%X6}l8VCLk>pg0AwUI$`e za|-;CUXdLR^n<+?K<65*h^OSPT|%xYc8AwM)+Ci)MZt84`gQ97 z6{*0GNf&3Yh9^X2K=1AEX?!ML2XJh2k4M1z=sq9tH@xmg_=8XRYIg**$h>7m?%8PUt6YcU1 z8t@98nFHixLw$wyv~}mA#%L>i*mqCA&N*+JKIbW4uWPS;%rl;gZ~xUdz4F}Oyi_(^ zDop;pUhn$8m)-ukKk~JQANSWE)!+KN*t=ts#%gl3n36Jsm>5B5J$D##aOhYmgEA2a zINZ$(&Fun2RIF(<)g^Q?6BV681A74+u7NWLz?n0F2cMSj26*$G@E^Z(=8f;0eg38s z2XZ*Ry`)cZpJXV+nlNFp- zq_x2X9W+%kdav1ig@X<}l?HyDmOH4Nm{qy8$;-MEciz2yZs67PA-yVtz^ z7?`4HDp&$2nGmF1-X3cZzvGcECK^q$$QpuGz9u&ssgi5Ew6uz{cn-l))ve{QHOc-s{eR&1!!HiX5kO^&b+kdq@8uIBa* z-QC}ZPkPjUm#=yGzj@kY^BH$N_gR1YVc+^CKYjjE`EaQ)x#;U*&;N}p?tJaPx#^33 z`rFobJp6S2((9IK?WU0jM_VaHqlK(2T9hWfqBrT?xU~+AP!{2{+VU=4yIbwitk&)# zw8=Pg5I%dlp&bC*4!-C1^o{Ra;@k%pe!m5#cO8n9I( zhZf0thg#gUvXTA%?Uv7Y`TO~^*L{o|vB@Pl3)+aWaumD7c8B^#umw_`1=olQl;x_9ZvN?~ za{rtE&?)eW?|8d@=kxbYpbSW=jtocVrQ{4TcY^Gd^=j`Z^+nMeWnd-Y(m`Raxd@gB z?Ae$*pb2D8_lN+gV1aLfCF3q8TS5Dlu8j^xz##8)q|+DljaS^pf<;6tC`>|VOB>yV z(gT1}^ay(wGQ$8Zq#7bpQbr6)h#EZ{#U6dc2pU%Q$q@#mYyks_^rp$#FRoiu#?m5s z7uK6Z&L+`ON!L}QNdi|fc&|2_V6~{HT|GuFgT%+7d1e%yb}9N%^crZI0GpkRB9<*O z8`+#?u2tp(3N3||muxt8C_94^XHIUl$uT19TPC6fsiaGTJyrLt2he2H+}X{TyYbyPz`Om>v*AyC(6#)8%Rd#5Jo6AS_IYk|H*Q}(!jry5pnUUa zESfD^SsT1d#86@JDtm({$+v30>fww$g4D1=Pa9l#PPc7d#;XpWH^222zjpZeulXA{ z{_rn+=H<`(yx+Pcg1uCjeDv!vKl;9S>vONw!@v2BA9M1nKmRA6`|9sozkh$ipS=Mf zS6GFMBABQPwo6ur#c;L*w*i4Y3CtZGd;) zxbQ=NbmHy%PmJ>@l6<(~%miGsXY2!LtstUBRD);i2;#f)q>2a((PCl;m4MBF8g^J{ zGX)_=(ydVR_OVB(v<+>9$qrE1sJ%+6K@@ox4VTdEdFc}hyc#xVuqZp$_*RwP3t%#P+Lv2NYV=pKj| zPKM|NttfPP^e(m?CGCFek{Fs1K?oP~Dyx%DXQjh7InUyJ05Fz;tSt~` zg%CE_VjIOTmleQD!h4S)aE``fNbqdY*@m5_bbY3V zQg(7{)kmrso~1OSjVL)YDx7XeeaceAO$8DL7ofMWvjz!$#G@dLOe#Rbv$6o_p^`{E z@r&MYKY%q-U_fi&f6&Esq0(IigK}R zGKNw^2t;Sk$;r^t!C1BJMaLpjvtTa+w%ip~MbZ^bu@oA0BD;d3m!={aLXiX_;cTm& z)Vt=Wo+v<5B_cXeId+XedTO>bC4((2+H1iJ0I=l)Zie$|c`AdD}*O|9fxnYo{A{^&$E3mtTWV zxbl7KXEgesF2Dhd!Z2MQR!}5~&6> z2y%q2^{K|IPQz^p>!AJ!l_)iiod0^kp1?E zWeko zONfE6g$P1r4+cuSNGC5O)&MSYTnrSIO}mO34T+FYe@DUDCa4($#F`u$p19|2pM3A9 zaqk6xP+bYM1N11-YZXjQmZOpCh!}o$xEBz=oWP|bQ6a5tut`Lvy|~tAd2UBWqKPa? z(#WZ44WVVkCNwCN>s7P`T5_tDjyL?wXYh&7eB-?~=xEKwcXdwI_i1uh>U)AYm$r_pJ$suX0gUcETjVrMu5Y`2(3+!LCtj36t) zMocWk`==qCO42XDevrxB02=HvV+S3S-T#$V>0x{G%l-4+Xa zVJI0*V8(ECSL!K)qc+c|;OAzrQIwcq@a^FJ0>oqiU+`|m&JuYK2< z>woS|KXmusxm1)~DoifC9{S?4P3cD zVa?&Z7&LIDvE8*mO1Zk5lOv+iXeEyT&I!ASm0d=C(b+}_T(k<-3L>&@Yn@e$gb0pX zKTvqp9PAAm=pa}%T6ic2E0+FBlJ;~4WagN!11!m7fil2}L6CBR8{o+Vl20)<@Qg(?XTXoUyXK66q7)C2W zlMq-{pD3nIBG8SYbLtEtH4>l7iR=QD!K&fTAk|h5bp$iWkiiJ0909|bDY_*iQm%}> zq^!M*9wI;S#b1|~ecg|s7a~>(wvvL&@>H|m3frwljU9+ax%kBbkqjVdl|hVSOat|M zaR0)j$P9;>3{C=|MpncYobEQJ&UU}ww}1Zh7Y#vl5N?#3iN?mX6Y4NI+0i2$h8!wg z5h!HlqK24A2#o?dmB=7lbUA})(1b=XkhTJfO7uxhZXp;A+3kp6Z8F8EPG@mQrU^;` z0kA_6U>|0}E=MHLq6l-y+U=SOp}R|ApusL+vH^6Gz#72esqj@m^u@YsYrOj1@6l`5 z?}<--@L4|kL66CYoc`E2&LiG-az{WNjq7@r)uVI4f*s^EsMAud$J&xMVKZN~5a#7BJd&)g`#=MG~{;Of)F)rZD@^S~z(vnyhE5zvF92{AHN zN?``b7*hWcyaS|+G%64TED@d|DFc_`&e2_IC&j&1KE9Q5a7BIGkHo$l^)RJ`l3T(6C zZen(zXZ`w_@T|a`n3fFACKeM58)g|>g}MSW5Oc2BReye~(1M08qAT1Nw%Kmodlzkm zH#K?Kmt|*t9_U7+GoV^Qwb{reBTm*Qs&PLx0F5|VyFO=;N(Z3&9Ea2wJ8Nx6AYt?X z5)*8#M8_2Onp+=z@28D0LBL|d(Gjo~lp9A7I;!i_Q6Y#4sRLX9 z@$Va(k^GdXF=K++VJ@|LBkSK|k1C9_e&wD+p$g!M&w9P@dy{EqL}X|6C(~)gltY&- zH9AX+?n-WgP{d@6uYf>cuJhelR&iys)AeUXbdhzx1~c47cgUiZN~9Ejk(-!JcVL>; zJfpc=*eb9nE7i!xY;x-svw^t>7NOQAAZJ4dnVQ%UT0|@v=z^DQmL_zuEe^OhXWCl? zwhk|%WnkMKv%ub04}8$c%?xX4k&@`i9c*0_oe>LYyP z znOPO?;H9=u=Fk_XN5WL$Y~5LtG9xoF_rAUh+wg0)Q`m(l7g`UjKRRQd#j|X~$&!_-}ael`s01t46_dcw36*P!(9CHssTZD`!U2s=wQuOJ%PWweHN_Oig)GsOFDHEw+C3G^p-11Ae` zFcH`6bC=bHK6vh?5o`qsAuhnVtGLikabFP~HKQ*=*r49#w2CpH(KIt7H0gH$)$9Za=TYT-7bPwSbnrX)tQD&M%Yt7srnC0R-VAqOU* zk0cN!;Ajwlsnks{1)-tBR@rS}iTk)LO!Zj=xxgi`XgSQjkd;IOm@b`Bl)l@tSh>ulRH&g(AM@540F}ErU0(~4W=>%`RSAYw zLXw(NPN3^Aa2bnfv4BNziYBK@J4Vz3Mg;)WRt75=%`Jfh~!Nt(t=rA{lN_Sg|0` zZ1my4>*<0F8$woO$JoBqM$>!u`Ijyo=z_C2g>g6$AA5y;excv~kz>8{j#tO0KjdmY z;UQ1O+5Kzz;q$iwI;kzf;pU+1Nalrr_pzUnYDyy_(9{=!!Hqz1)^_hVv|b*}fB8)> z)nmW@>92XoFFfTFzW(!Gr@#B8U&u>k#eanzlj*Zw{PYKZ^5g%(V?XDi{T+X(sc%jx z1857-ffgfQYiCW+n4Z{%*a|2_Xb3}NuCx_hei`xbhXrpx553_R&iRAa-$=dvZeyTH1sJi-NCR%n~8tFh@xNifgfE&@Jk&_~%>Ok9dG-|Lf$N`l? zt(?5&0IhuhW_BW32TO{(!V4)yfj)pYZ(cD#~ z(YU;GL2zMUk3|DTWxWo zRGFL=WDqP=S^)(FnI$qPgoH>YGVbY}o^{{X`TcR-&l~v6@EzYj?STnLj*w*Uowc85 zt$SU=dH&9ibap3TFxRe}dzk)|-y$GPS*_(g0dN7b<^it?a0kZxcNuZJK+b&{`+W^s zg{(pS*8wWRCP)(uJ0K^ByAXF9S|DrJ)M4NQufMe1{shey#Uc`5Lk3tU1f~Wx!E2am zxCg}=RE2l#R3=vD*Psl=V>Z!K5Yb>QmbI28`4^5R3z160R!D4;hb$&Q8t?3Wc<$R? zoiD;fGq?07<-RPaB9R9*#pvF)Owuu%&t3g!}Q3-|A(yT&-Aw;$S znuUveF0<2>i{;X)#TD3 zl9Ga&T|>4}LgaIfzUIvg>!51QV-Ep~0HcbrRAN^sS{C2Yz#y=w!lD8D2H6=aRt^lX z_kZa|z2;M|$K^MFnCGsZqkY36Mv&QIR4EPI9gvs|Ww2ptjiCv(O|79>KvrX+3Gie7 zg?hBUl{0MYzUtrn=((?c<9q+>-Q~nT(uv7QFM9v{*js=14G;RAm+13<3*Wv`D06wI z4yUb9Gz-9#9B$IHh3WLruGiwilG1%(Kd`n=yY)En`S%>b;uA*=aN(2@m>DtU+#OBm zj$6X^bPEZuXbE3K-rCE5m&nCE`F^K>MoU-kl&{4;itRrZ%&fQ|JlW;heJjwpSACk2 zmQS@Vxky2wvVDSDnAI+atzcMk!o-A>fnf!ZHL-%Q5&#Q=5f-on5S7KyCQ}2=8dltC z+Z8-GyF-{3Q4K&lwt4IU`{#4E`G|-FVYN_hh=S?xh^6%=IWVmomXfAG7)sZ0uxIl! z(~_&ms%}8(PQPL<6q_%Z|7Ry@*A(?Q~%X-wG-{M!V`wo!L&(m zZh%#oZ2@7oIh~>jLk0)}Fwj%boGfuh^xB~!;E@4kwcih|<=BNu*M&8L#ZVBiDf*<% z3I3t!=hNES3{<6| zRV<>UnJff)PAyZ@jP$*CN*ZFBkCU1TP|M+^&@u^}J*SV6b}!PP8YvOUlY(Ud)WO@F z;Tv({4#!b1|K=A40F=Y0d^v8}-L{i9^V;l`t3^B(B|f zRaHjy2ti6VN#T$=(+tq1XaFl9nwd~Vvj|uTT3DZY09ItF%@;g`i3pk)krFVXSwRue z=;>&S@GvL`kC8k-W{@AyYHm&0F}Bvw_wTe~`TJllkl_w2Do|6y1uwvv>#(Cc&~JbI z)BJ|>Z{(9Nd=}=r>$rCF>U@0V06fc1v!DVgLhce>l0wZvGocz=92e(|$8?ly<5AJ&2xR^Dxd&Anb%q)6`qZ<9EbxB^GpOWwCVPNx*>q8!77H#9g2vg3+*l&9ImS>eS#^ za2@07###Y@*$dwFl?R))e(6$M{gL;^js~GE9G+;eP7Ae;v`kKIOB3)?mWy7@rGgWX zR0R~qUcg|Vk9X*xL#=_Xrkv}fQ`Vv>0Ac{BB8Z_%fU_+Cc*rx~`c;L=B~N=}*Tr~M zYp|7&S!;8X4s6f7fr5sp$s}PxO$MwS^tARHN7Qu0=T^tT5@M~h4$*Lc#A0YjYJ6BJ zYAQBNDqNVyFi@E7h8^5#K9-$3EJ^@Glqoow8xX;0ksj-aF0!sIDM899SVTwlQYLi9 zoBH#*RJv-pg#8NhoR!pec{7s2M2n#l$^>UA&0s{Grn@>9H*6!ec6REm7G>DKsnuwM z-?~oYqyi&{F?S!Ld;kYHXLbyTIT%7kQRE6L;KC@=c1-5YB2-bMU?S4<-i=&~MHW|C z#h9hdi*$<77`dk_l(p=J6e<}kMuWvfm|H3;9Xz50aqXS6+QdnXX?z=b9wFYFG1d977Do` zA}P0N;FO@fBR=BchZUPKnSuK0xJhhvEO*r zmnPiw-lN3T+rs`aaF5YYqj$Q=Zr`)`Y7Z~2JzK9@>Jsu*8B4B04%Rc_BL|J0#@ZsM zE$m@0kqZAD{WqZ)&{r$y(dpeKlYbo(H|bTTHDCxL%#CQ`g5jjqTG6%;zGKkNF57N} zj#i>6aJ(6ICZH{B_e752xf2KmLRJ|X8ckY}Zaox^D1nIv?E>?%IDF1g?Aon&nB7tO zx?ylOge=ob^2J!AzmnA%kswhl-+%zaEKeRRH8VyNELlz^*YaIav9xSJZiJ$mh)koW zW$_p?7iUoNo7}x%wmT&9uN$8f#`I z5$Ixqy-zI>sx2YQ}2l(TZw=8#vrrIgU^*t*j1X_*7#&mF2{(`{6Ok_buKei%QI=6nxrtIkHHR4jY_q{918iE{@#^%) z-}#&VvTy$GHcVTK-J4g&zj))(V*3_2dMku-w6_8DVxo;`G;{0T=G_KcnQevxTr1!> zBIYIIA7e%xsKUMxC=Q!hi4n$PBT5-KP}o^5C~b(^DjHg-H3eJR@%rf4Ak-<~beUU1 zhg08ds%gwMLretCCj4eVE)hMxn^vumPaybdtly!qj17T20_cD#JW)LT~ zFmZ73-iKXR9&LQ>NB;q@x$|F~zV4x?Cg@Fi0!W{O5CnO_Um{GxC!wHWz ztHtKLX8SkCdmQ+Q`n>Oa%$cuv-G_e?z)##=TKpp^OyV=|de^0&`km*Cop~FNWLV_9Es7hAc!UCy%z&55hBaA zL#}cRw-qN!tw=|#X5rFBs;tY|N*{gn)eP*m;-fZn#&z-}#w=D!fjAbn)&e`#xZPm4 z2z4jWwgEp5?B28qJGuaOf*#;b+xQMO#{V#j_J>T#rxBnge!E;W} zb;Dpgm}+Z+H4?3nK|_s(kV`o;R!pflC85^H!O}g{0A_-7Muo5uv}`7e3^hPE@7;A$ z36cyObh<)e-cZVjm~iKC##`$CGf)4Ie~j_u4eg%T6l_Lno5Pph3k>s!aBA4jO&wh^ zy380>cPxisBY+(eG{D0$Q>XdyTO7?qHYsFq2hD|If=9!4OX}04hTUzU><|C!wTs2A zpFaiwyRZDGHhbRN_AY++SD)y!ANtcqFS#)zglP$%LFvUvM)@^}nJJyS0cBhf8UgFm z`(@$V`W$CZ#R9Zy;Vld$mslnibro%*SgDM48c^vfhTH8BKmPXT@qtfzrHFMs<>H^i zV;}Yq+d8u2yY*JJ;j=`sR0m`lFJUGrAQ4`&!Ri^?8_SoijM2xEG*wH$5csoOXAh`g2HE^EqRc46cBhYCJ_Q?>v6!9T_iKjcZc z*ZzCNAugej&=HI6iXtth)|PdriZIbxciht;zX$g zX%-laSU*tgsv81F-g6^x(>8E$CTx_xwrPS&kK&9TrnRJ{_SDtfk#;ev0#3_r2*fg{ zCaK&hNKA|y!N$D~s(V3_GSPR@gUel{L{_35Z)lfSmK$!4a?7T%73@~1&5L}P$qxh< z=Wp2l2Mz7u$%CJF)?62zw}OYBy~eZF8Ru=FoHQV21P8!~2?f4v=dw%BxZ*z9sRowl zCuuC8G6~y>~~Bj9(;RUPxmj!KS!#Al$S#&+hj~Om2e-i)z&h z5W5#Xt^mOijS;5K$^t;OoF^!mpo#Z1D?Obp1KOa)UUFV$EEU9_NQfR$lbH>8oq!nT zHXOOykNwh%4fqhOq)l}9zdo6>A9$;jTyHcVn>&H(fq;?Ot#>(2R(b$1L3BVhF?zWu zOp)!9!iHe}oGO|DFor_xsPM$eDQjvPg`5abrGH^A)oEzz!H$3ZN8iP-f5va^y+|MU zq*n?6hMjTy;Vp`$s+Db}QD`KI7?a^@V>}jZkBz1R-RZZ`miH zd5Z?bepB>GSh};&w;gRc@651(Aq$z-Jyh%wOBpYb)-!dSFsT4TdBbeq(XIQ_kAGHo z-mPr!n;)`+*cWAuA_EUU?b||925|KNZ}kaq-)*K%KBE+sILhd;r9?$*fo4F(A{J~~ z=)~eCZPiVD;pQ*kn$6GYbJu@LpT71Z_L(nUqswB^wtT^XTXFgU+4=iir|nzy6%MFT z6_RQ+RRRNxRcm=RtDu_bUL}+Wosz1myodqJSdkd4bvrNpa=~F1( zm5C`wiY{t$z+1)n`+zSW17H53x9f!u|7M+k_%vQ|)3tZu{3Vf>>D!>CnK>^6sgcLpR_1E#0-+ub-c5^E}97hStfnW)Fg=(U^ zlJMVbl39Omqshb&l8hNVwmY=deUWUZhn>XcSl=5c361f6Uh8{GGHlF<=CwDdi;T(C6O zA_%OYDS~Cn(NKyN6h4t*46<3>q77`(%X8HlK^1$18>c6xiaK>J#3Rc2RJFS z8nMipTmnCOh)W)(0)h>h#S@)HDpJ-HZ`H*GE%D=|FX!-SOR?NhqzfpPnMRO#fjoB2 zeVmnBa@=tH&HZkStLukWV2IeY%~~)7Yq+^tpgW$LcP03)qE`jhAMh4Zftg$_FTr48 zsp=4V=+e+X-asLv#)(Y3XK6AR8#Tkooh`rV&d!?e|C6`y_uhS?(URR0vW!1oLg$p= zQ-q~-IJ|=uZHZ9L+Ou@7ZdfX0TNlhr$b|_V`RxeHz|h3%%G?b;GO`);I;=K`;TydlHQ-iW-`#@`*$5 z#i0Cx2S7!J5p=`8l~Y8J7i>Hf6(PS*4|T*2Cv5wdH*aX~{<}BXn?Ls&zUObR(iF$r zDQChTaoP&k%cv@2LyLqz=h;*YC?Ny~dNP9CI!(7ra5{Bi*;0ip!Pe#(Wd;&zk@Yt` zQ>++iu%~G`K*7}iyS2ynoVp*l{si!!Klo1n@k^hE^ADVkFCM)CfQxJlFmgcH7-nQk zXf*&%O)je_t*I4Y3#A?1{V1ODpeOU+`5Pbh3vYb1zxe6zzuP+ewOW{z2ma{yob&8w zt=(W~yVowX;8-zX2u~+U0{lo?wpH@Xw{aj}Dyu~+hrr7}Ghye?uLnj44vy#r2KJm4 zIWZNQmPJT!9+bjV@25%xvk2(Vc(N3&lRr{~>s_fD2?oK`eSst7^8yo4uiV1!XO9Xu zZwV`-;evHs{O`|-AAH%0qzE7_heAI%`Y*&<0@eU||LAiw(anPeMxrv1tz{onHOLsD%qBw>q8O86 zTOiH=Z_~}Uca2Yf_LC|Atgo*QMGhhsEM?{)3yrcl5Kc*DxiJEb$=at+RG}^kohj8mT)wZj^K!$(;Llvy-_(->UyU(PlBy6ky!QF91{Y(lS@B}9#-5%M$z^t<0*R3^7ecPw94X|*=qX_@8C%)u&1@h% zK$kwz7&r$F~b`0M$fzh?ZWYNDfeD`N0q!qR7s_ zYhCQ#jdfVQ6I*{)(o9e6sW{Ok8Ros9QIxVS)p5spmH3dP=k~ih!%#?tqWrETTgv` zi<-(K!qui8tfPdo2~=^J(1fv&V>@g8Af9vaOZZ(6{BhKn{G->rJAUSa|5|VP$W=J$ z+-+WR>Y6snI;Xa&iI#cHtyoyVD-|ZORT#i1{pce_xj6$>M#~kNrNox5UU`^FQXpFY zWIZ)s78zUGRlDY$F&vU$z^QA*-yawM%U`_-KlaFfZU@#6;g-!C0k(h&)laGPVWmSa zkd08ZQWwr*Rhn_Zql@ePl$GQ7spotnU;p;Mem;O-xx2*p|M$oLu^f~0|J&8&^q;?M z7Kvr=00QmXKcChnj*8(d8uz%hq zB>iAmPBxcJh<;G-bwA5PB|WLurzNdZS^gQWz$$Vw4L62-;AVJn2soSd69m@qS7Ccwz>e7C|X=)KuB&(XblgU1G| znT4LGJfykV1_1omr=R{0M&aN02iNtZm$7whbDA04=EcGp0j7e?OhyBZbPQ=*G$}!5 zovv?8=p>z>v$o-*M6Z&vB3kMp()Ck_ zVIVN0VMOO4v7mkP-php7c`Cm!tEI^W}Rh9=7vo+hP^4ZGThf{tv-tKuqAI6y#+5luU zF~~48Sz@_Vn#BNv!DY>0&E#@eOBaL(;WAj5vWcK6OX+-#M|KKJbGeHcWEkYg0|O0X z0M1=o2Y_Gr;Jd!ct@V<>d>;YeSHAI^wI21TF9Q2lM{_S-MPGOu)hk63SzRod00tPX z?{aby!qU^KO^`FEDgSrSpt;Nk6^yh> z1mJ-P#NbRn+YE3nA>MV6`usD;tl7l$B(v7(t?CKB z6QGG`6=(~gO~7K8hzZzP5N%7qHn1AdjmKI4@Cwv-T+7vaAI6XU_7io>haMC+|NJ5R z>LW)SDvIshL0>*$xN*~Q<4$SECtwSPi34aQSe#}9FfV|cV(Afr5pp%Tf(6YClBeBH zY4)i}JW9;7AxM@h8stI%03ZNKL_t(3pc+ZIpDMIlrD*|75+(p1U@>4cadlwy7ZMKc zKV{$dzw#5e+tt77pXX}lfByI-m(R}I|K)S{-GKQ4Q>@Hvdv$Evqt&)DuZz`Ln}$Jk z=BTr|;B#Ocz+nJwfR(|*UDZvCVTd}6p`pZLHbfmqw%HiFUbtf(i!$Savs!5pg>nyQ zeYCC9XXVBZeO%x8xcmK!+q>4MJ@XqU0JzB(hn=K3jYineW&nz3K}NH5bEzk`)0$fd z7NSxJlhzi7OiMdPu7Jcw4!QFu5iBGIi7wDbVQ_e1k*$ckn2n+^{UQv)%=OSTlioq?ssIkOiWgd%-^){`X89eLjhTYu(C+$D|T>x;ur@nGe z1bfB1Ubc6(TzBHc6Ia@Hju=!2fQ2Ao)yze+a9G6zSuk9PA){#Il)>2?Y!(RX5_D!$ z(wr7F2E?jJl3lM72N9%)xy!w{!C|=d*l-#Ey!a81|EkH!|NW>-_Wu6-vv2UCA`}OT1LnOjv6{8Y5E!h%3@AnLAy6Q=1qKHO=8g;i zI+^10KfPRkNpSkoY#ElT?POL4xDnvzF{N6*&fBor4=;&{rt(RQ_+dqi6ZVkBl7;(c1;dlVWi84xs z^3f~?D42$YT=83vV(BDyMJN)5-?}p?m%jNfOz@CFlC}IPkrSp!`UV-1PjKSDa)wGc zGC&B*-man3m#~O4581f0h`U??u5WMs#EqZ*3@$u#&_--yy-2IWXa$W96eHn88z`9} z;b|mjL}4-wilL1L3>4geLb6C06b2jM=0PI7$h}ybm7-yE96UT?wzx(a{yre&wu5sUYu*sd7S`Y_4M;!wXJ=D(`Lka zrU+sfs9CZEqjk$i;NZ|` z!fZ6o3OOG*99|3AXrDjkAHDQ>uRQt>CZ3P~@wly_x08Hj1A^oMwVaY6xCHI0fyUA` zx?~q-sjFnz1AIF`h*V-rX*t2lfJ9sJ3N89_4Ys7ZME?A!`_}f4cXjVxeDC|bH_*0w4JH zYwguf_>b|fziaXFTW*27pctYy6B^KScQ`S>vuwB=HPLQpH8Y1gHD9?gqb8&)JH@I= zlWsypjM(zJOypH|eC+GcmOa$}CvX)GTkKpwI}m z1~#>p-q03_wK~4@1;RGt*-v>S-uJ05Kl2%ncvnd6?!x42r7*eY5B`a*oPX~JZ=Gzh zc@1P{sgaLPmoMnrzeuiE^bGN6DEo-*n}p+UxsKR38rFuy5Tfsbmt*hf?ihBPZ_*oc z6uS&PlYnS}Ktp6;E25K<(y@@gPMsPSNxQ;o34G!R@uy$nso!!Y-hJg`{Bu8ZM%?!x z7}dDFU3J4&a5u=+0UF&fFl87PnNyEk#FHT<#E>CTiij>xWS7%1@|_;+B>X@h#iwA8 zU^t*$p(v1{-aS;NrTjo*H;qUkq7*D`E|w%{17LRM$s1mU19$cPJ?JryeZx(k{@_Q( zn=bc%;wIM53jU~&T|~R?47qh=wA7c7L0K>RJhO_@szF%7V>9r5kc%N7 zFwOU!d1s&Nc`td>7j{->@4&`c#QqJT%mYIK%McibYQ+jhBRFsHk=d<}1V^C^Kq)3K zmLy^WWR{MR5Yx%F3MC%V1U9$XK|T3Rh%4VYU3O+n^CiZ3};}_A$W0?ks4O59Xfl~&RGZQAHCuuAAZ@3 z-f~CR_}qs+TL0n^j}ZXOxwg7C-{`A4@3Nwd&oavL!0g%2N`p>X5to@;$vWubnAXi# z1iJK>y;$_Faj-7if)wDDi?wn&${1_`DdR0F@E6&)4Q+Q zq|v~j7U5ELh9Jl+nz07aD#7NV#s~q4LvutkwESm+TrEt|^GPL8HE*ug0|M2<-V zw#yK8j>U-Rid=E0RU3|P_Bb|SMmt!21DgBXCqC$vnKNL`SkL%^@we4l;O zMGx1}BkhOtBiG#&*sqns0E9q$zvRRxKloiIKl#!Ag`0q=F~}ijYCoc-`q8t|UdHty zjF_z%1^`Fic4HPH>(&d49A}~`5%B4@$>%Ya5VaJVbUSc=L1mJCVzrnUa`jA=R*6<~ zjT2_@4oUlqYYn!-AAR>X;_6?y2Ohhjh+S}dH?#vuG>*kFxI|e_b(x3+1_5~QL(*MD zWnc}h+xq~)wVZPx?fdE`psYyL|2Y|{BxmZmOia5{iU|W0WhNTZ#30h4XaNIF3iMwf zw`7O+trw!6eOKS#&p+YR-HX5BpFQ#Nx4mj>|8<|SCq8ncoprL+eJkN*edNIv#DNuH z{|dQpl~`XTR#y!3xpBT~SRZSw4O6TQm1|{+wPB&vvBumd%w`d5D>Iu9B~}O8e07Gy zr;PQ&uPg1rr%c1ge)~6YpEKw8ebIM4>P}VhuYAR8H!iy1UaNpn-W1k)(5MSSSfHhT z2{6lQqxbOWuS|7n4!h(zmcr3UMMi3Py&GHil8UtllOqT-W}^*{?Q!Li0I+!LzrVvS zYva6s@|^3wy#HF9d@sYvYYj$VKChS!ggX)5V4j?iCBV-Oxms}2t)UEnMxl7XEMR8H z^IkAa@M1I*QG{_+%-ytNZQPiF_r4HczG)Xn_MQ8X2miqDHvqWefBfuMoh5(&o?qO% z{<3k>gNivC+9;YaYKQzf6NgM$zbo^hi=~6S(^Hobh0%8lOXGGQ^JPhGB3gjVeBQ~@ zHQb#vbO+eZIdJm+-MYBTbMK;uT=MVgWgq^)=^y*6a^JJ&IB?e5u>a(h-LhVWVK&6- zjF=6M*=RnDrrDgFtr&(8@)>PJ!)U_j!GYk6usNuK;81{~!HT@vFkn0|*P*k=IPC&& zhp{={{JP(bZ+pTe-+X7+_j{lC%e~@ib9Zsi`A}FHd!TAt_DzB1(wEC(CHo!j0i~6a z+Z58ttjqqTzHdrjak{sgl*7X8=`vVikBfjbAcG`ag0ZGK&`5KG3v8@0;hqN`$S=O& z8f#mnz30ov0S;^wYgn{!tC_N_TEzy(B-kWplVO?GERa>S+Q?`m7_2$FNqL|lG76*r zy}%wzrnaO$)U4r}fm0LDUN?N^`UU>x`j6oBeWxO#_4!;gsHVleugUWR2t_~>CR2+> zE4XR;l2+%ZPWL+J)WbjfhWj0Q?l=9#-G#~5iYw>X=CK#w>yhW+>d%305fNq+P|z#? z<@f|vEXCVI8HDu}!{^>{D{%8>;H(wPJBX=AZ@m`t1}u=6_)K76Px0+|X%L}?hO%co zsamoNvazU5J+UE!`bwZ2TQEL+E6RON#udMRq0StE#YCK#L>p2Io(j)~2!<;x%z^M6 zPbbWpnXrcLdlN;50j5Z_XY2d~A>&sAMl#tS8I6)KGIqmKu5-|5B3;xFF+p+lEldGSN<|LAh! zoPE1TfucHB1TgJIz-891^6@V_yJ=R9G+>Z`tO3j!vAdzpX68eItvjF-6K?#>XYpqr z{NrQ))SlBdHMwxUX*yZDrd4o0(4~)3?Xth-$!g{G%k||Xt}=^ zEINxejmxGz(-s0`dU`xt%dWvwCMGj2>#7jVhKPe_-wy!BC;yyw|M;bxJ>@_AgG24W z_r3PmRloc%&cFKltN-+@GtRyg`;Gx8wu#z=sR?xvm=8VIN9nUNB>jhSs1;bL2=?aM zd&o(Pywkc{L}nN)P-e~NBT?qWxX*SswR>dk!uvn``LFxj-@oH$c+R)}q|X1ApZF>< z^f`}x(cX2RamIbm5ro){urXZbx=n$u4pZUMS#RC0vMfDl+1GxRUR?+UNnhG*^W!I& ze&T?4NU(vB76F@^$Gy%zeKx!7Z&v`^DK`99zvcc50FU}-Klf|Tnf>kM|LMX9KISAI zI&FUZ;IPvsl;cy2pji`_@PGyZo{3gvLewH!L%6exp`%ClQEaoJjy8iE%m&!*4O^)1 z{sd0E=f7X|fQQ}Z0l)CH#~!`2>wDJ4w-wz7=d0hdT!^vsxl;eEEH$vVjh(tAO1Z+N z<^6$7T(Zs`=s!1XFY|N{MBU?>`&zBjQM*${(ahk| zYHDm`7v(U4T1%;JE)~@`-mgA};;Hm4}D~s9lDoklNY3czheWR+D87X;xFQ34YvhDtc zv7T9zYokc;abNj3Q5Fl-58sT{OHaemU%wZMf=72VxXp{8=4i`kI=U#D;IgjZP`;iU z4PF`%CD*|HJYeK-G=jOc2{;mo1VGUw^GJtM&J7cul@&>ks7dc~U*0d#EXrN*lBwX7 zI`cN$)cnk423Od$S^~AUOP=?K*SzwT-*IN#)pz;IfB66bc<6Ip@uBm^fGl*_cnWF-_abp>3$W;;^{qyjLrv~Tu2gS}Ye)w3@6y5;rVUQx3A@v6s&RHih|Z`v05?)mtd9PE$t+7(SmD-YA>+}YZhQNi3+oT znhz^315{N~Cq3*|jYqe)?5}qe-+lideBBKIaL%{>(o_pyJ>0Q6|;*0L} zu*c0C7JwI}iV5l@wLV<82*ALQrQN9C3S@@=g!0~Aep&$~oxD#66iNWg-j7&%*%O(s zD3Ra9sT&8eI-A`Sz$aew$;cdLi-9p_u z7GvGsS>M{a{D2*Q={tY_=ih$i?*l*c{>$tqAASEje7^qhV;}JEc+ex;|Mkajn4fX_ zxfd~aFf;%TQ$oR(JKyCwBBkps`VNQv?2HD&8FX5zL;x{|9m~EX^=5g;=D`_&9Aw)A zxAk_Xi+wD|x*1muQ{0wXY9=b!w9)qSQ!o6Ko&U4Xc-3hH4+xO_C08keA;Y{LJyc zY|#J~_MS{Qiq&L{b84}56*gSY(U58Pdte2o+) zn}7e==j?m#OXo+AK-vw;3tGRaEj3v1mKcWYP{@^iremAn^j9|k2Uj2uq6Xj|P}rWR zbuV12c01o!oM6k0Dl$>@J-V!GyHZ(|f)9h5umhU0&9Lz8_g)9P_^@vMjk6^*+_Gcl zMPd+DnbWmovsnb377;zNwFE4HHqh0gx7Es$JP#PApiN}t>dF|I6Bw32<}O~y-wFVW zNK;8>J21GUA$6W!V(ICLqREirOebLEJ_`?reejb1;?&v%G zu|K+=|LGa0?H&5R{rrc08o)DO`j)G>dGzSYfnnuv1V^>%nLwBb6{l**!nN3EQlzmD zpeYz?mO%8F!ggAVX)x@R@$AiCdhP?ZzVg3+S49Z`oO9uQpIrbbf?>YY0aCK)6C;;l z50}|1z09tsE7#MxtG6Z6CWtVG!6m{|i^zO=EQKHEwm8X8W%|0Rd6{pn|& zdd5%v-k0y{`qvH|YWu(MH{WvOYrg+ajxA1{-F9(0s^n_(6N`3%R2F+(vJiqtp{g5v zu?2698B1ue#IHFA=VZ_#q*XO+ry9g72D>>}uDRg(zy8JlFVFC~m;R6d;DlCx=%ka+ z0qQgr^O6e%qb=(P5slHgy8KE`dn{4Q0aKS3TYfF3M6k*-YwtEmih3!Y4{2rr3uf?u z&}?Hi;*gEc0q}`CDoOxAgAD+7|MR2op%?z;7mvQ_nNN5ffY-hF9iQAd-llW5>&Qj3 z6-JvkImu893umK(7O@yjv|q#z z|H+H=sPh+gXnh5tnun)~pei}I41@A!@Jxfsr-`!uYr(@jB4D1>Rz*o>E2JdMrzRN6 za?Ct1b-kWJR+}(0hg-v6UU``=y7&Scte|D+8kKFFTC(r^8-XQQVa+vhVb%(6+`7i@ zdCJKFY}^;P;qFS~YoRb%oH+Kh;p-3Ev752rKvzITU>F(&=H{FZg91h$TElwrTiuHo%k-z-i_um4r^}q8T`;Wi)-wr?e5f8f;fJKWi zE5&oBWXgOnC6c1QCwW`wl1S@IuRYW$_v*rQE!a{U-QNuCC1VGCYPrq{XV_j^608m< zor-C`@kju-D}3B}>reUo$v@oyVE;4zivZx{@A>r#;L85z@{jlY?|zOy`nH$z#CYgg zK9q*2Vdf&N$2YSq17s#1b<_dai1vRl}I@E!x~ z`S6A!aPKotd+Li`|K^|g#c%&-C+_IlU-pO#c9;ME<&Qc~FZ#fh1}A>uVdotO@ag|k z&Pr`Be)`ZXm6wF0WELaXKd7$9VU}z;zRNjTxZsgf$W;I5eQtSvbaj0oq#$gG%gLf8 zm(MwdH6W}I6dVe{%m^q;n#8s>0B(IKtc-#0C5@NrP5<%%FR(wm^7r`8&wfIWdcff} zE{1_od+FqKL|-sRkJHKoPerf=QJ6Zr1R3^1sS&aYYo2hqmOQ~MPXvQ0S}?N}qd%;s zg<70Ega7RL5#d-JGW)3d5`@9Tcv?>ndF zqACNGu`2UuLNF5qB4G$INPwdms3EJIDSXFCP{(@6g=bY~y?&rGqwfAqg z3D2}Qw*Y*6EX;iDBOZMIi{E(sps#<;uio;&xN$!+zdqp&zfJ&n;&;C<0N^p-@N>tf z$^V!4`P;wbq52yy|J3zwe91%qtGoTL{E<)ou0-oj;{4Cvbi*}^kjJsa@V?6E z%~BPc!ik_@uOR!mR}f3pSGL~JY!}n9SvI#BxY#CHZdU37=pq+{ZAFfM51zYB>y6L& z$G_d*dk!V{hyT6n*FW;|=f2<%UcCSj*e&X5t+E!W5GeX4Ez)H^UAJ8@)UW6^`*u{h zIZxq`JX&3;=qwt#lVNu3bo9t=kP=pCh@)QqoM%1a&O2^@JOF(EJAP$+|F68~zj+RR z`}1$CZ-36Cf0K&wXMX0_f7fH6Z+gcs^M~H~s|0|*_0w;;{H0HR((`v13L%C)owng= zdrZ&aS>=zhePptcyEi;BEooB*m=T;IFv6%dk2Jo?l@&q<*9^B+YaG8)G1 zzr84zX;GrQs--h$rc9$#0V=HltQA;M%wk+|S>Vn~7(aWUZdh`Dd0Dt$#o`3wn$Z4k)hkX;7PRXt0y5Ue zt0SQlk_qA^L*b-w0lG>U{yR%%$`1lugLv@Cb^eja77~_|_ASOawf>`C(>TCbN6QBCI-}G-^^WXdP@P?OP|2ytr z|K`8`y-)iyU-62^1f@)*(&Wuq4{hF*-zUwJeRjD+qoRUsMV3@1> z;odSF{a(XMa8X1kA~1@)@nwJX^0&SA_rK!)ez@H9=`VTH@nx5nEewU|PUDDZJ}1hb zP-~Xco6x;@t-X`sW;?GAGz(0+>Pi*RS@m~-es1{%Rv4`+c7URB;5V zRD;Oq9&`1fMhWGHnP^=>{V2!QKw1oi+&pT(hT+hwo0QZa({hAoevB9ORzx1HsXiTT zz7kY$iq>+oBSHQug~UNy{0CO;bp@~H>tFg;^a~$HUECAF&{}E;yn1A<${IYZfl8FO z^VFK5RiU+5Re@EDk4U>gR1?j#TIMOtASP4_C|T9+dzWz*9(*P6wh!J_>&Zo2c6<(} zN>moJR+O^JnH&`oiN3E?MHX12#;yAc@vNsm?r`Vb`RQIq^YFmPaX%SM{tJJ}2Y%_R zu6o{+aoZ;qSlIRAgXKE`p$@l^Kr+S%ot_}@?%RQD&jYnu%$zp=RCRAfOX)T1KuqL; zc{wn#cE5JO001BWNkl0@xh55f6<(pL3RfR7-+5Qyx?{ZMl@AuyUEQ+^ zR5Dy_E<_kWXs2nakr%m676=cral!qoOnc3TiXmA*Us?ZuzN&|s4$)jEE|J)bd^tYe=^>2On zz2Cp@{+V0v^Dv=@KIHnJx#F_jNEHSL!6nYj2{WeZysz(xP9s#`%RIzb6Yh$!=!V1@ zb+si3&|uW8&b7~@HgUb(#m+;`NfD{s?Wkvc{`cIU50=0Em;dxXd*1Uu>wK+;-s}&$ z+Gv)@15;VolK0?ns4k5Qvdusy^~3Pv72tFDZj2ow42r29-%UHN z)>AMn^U;ZJe}{&BS7VTY;E*cy>L+~da!m~Ur;pr@Ift5EAMD|gseXw4B>_S0q| zvW7DKqo!|Zp@LS4hXPmsH(15k1GR!!3(Q18fs!`sp&t{4O4jKC_>}X|Eq8;za{K#r z?eSH}^q;W$iX5n<%6!h0l#oiR4m#9Wmi4yVZyBHZkO#$bG#>nhAOAi4V=ws+^}xt+ zKOIa0htIw8G1uX6r;1BKAqT?F?w9sDp$bflD-f4{;{e=q0$hGH5eWWzsIRa}Z3=Ja z5V3g%6kSH%{*shiLkqxa(re~S;S|;az=*pG`rya#tk*sXkvT5xMGhC=LD65|a%G!m z@WD>V5Toahf&nODD1fj`cBd_3J6tHq=h8cT)8yD zCbp(;R>ziH?Kd4f?OXRFS}rLghX3?KQ74CG`MhU+>J`s<_@#e;@AvPuFSzbLm)ZZR zw|`_CmcHT}zvth6#cN)Cwci*PnyF?|Our9p`y;v>NA$SHE{<1s`L=`$R#_Qm<1{Mr z%w-(_M!#26Fd^jrpb{3(HyHktz(vC2U-Q~4fAV!-@h|VI2ge;h_k+Lf0Q!!9@~uz! zqhI@1U+V7Trv8Dk!0W{R|Lq#C?W@|K%IIM5>7$lD#H9fbBDYAG+gxmiEii9GUFOq)Jv%uO)AcvvykLexrT5mIo^!SvS% zC0171aF_$x*MbtXI&~B_!ZJ!iMW7hmSE;%|Vof_?N`a)Jk{SXp+d;qb@elFbvLi>L zxIzS0r4CV*Sdj^3R#a9T%%r8NaO>%9ItFq5byuFh`R02b@xZuoKOIbN_|yMtclzKb z+^}4R$^$%tPE0{C{BJtiBL-2PQkP z*7@E}^A+21*)}!1&}+iZr`1v{U%p!h>i?W&;(VtQ(((S$5Nw;x5<_a2C#=W#g^g9S z>$E+Gf|4eA_hF;8bp!zyi`ZA-JHO(&kNC_#`_KRBefdNF1;VC@<4uOWK$3|&)|VWJt67E9uuL@uY{Jxgi(tkEsyOLuRwfxpdQD_Z40Db;^Gx9M zDI>?{KI7?etuB3d{@vgFuJ_)j+t$D28BZzzc+54|edx=6|BH5B3^Qz9Wr)%AIeBNM zPV2gV6Lb`QU?UrB-5vE8n70q z^xP#C*BZT)4pj|d_0S@bhalDpN`cIgDhO#?VhK2Z48H4@JC!1SU1f$a(iiIGrE#Sx?Y=eX>EfvamxaePfM{!O>S( zhjj!}p>}kNVR40591UDLWfn*M^j~}N%bx#rKlYvXt)yJJ;V zpXE&%@$(E4vl~P>37RgL_Gl*3-s2eE1WQ+*&5*el4Nm6x?QjZPOfwbCf)Z#;(JYMR ztSPBYPdF}cciFl1xv%@qzxzwy{1wl*KO83S`oY&9z5n)l-{*hX*IZHM_$u6QRYaho zrB5p(Y^7*QNv>X-X3hSL`@`!y_NJpaxdx7n0o$K)VZ)&+P>3GAb%{NdYP_obhI#ZiPr1{rD>wL-}7GIzBl zG>|}m?dCR%zN5f29Kh9wm7BO}sb0*7*fGm|)A za8)P~D1o^ysCNJE1!bBEfOOv1EJAPvxpukIp;%Wgz|9xHoA16AR~(;*pvRX{O5a?i)~vd?;Az4xQ14-6*vx54CnU-Q@mfV}l1FS_DsPr}{z zVD$%H04o(hAe7b-(M@*15tz3o@X0%YD~|hC8o*{ioEO$=l6cl^c|sN4Q&Kj`md@sT z%|E0)DM%obI9a{s$wMJ8;?*yCR6>~-SKIBp<*~18a?4GKGN-onQdg~@gft2o<(94X zn_3NpvY%HnnyEoaXfp&|RHoT2i22BqN)jqXBPxI(B^hF6{Yk88_H>oIL;rTYz>5ux z#-6ZaaKkTuK&8`FaeNfZzLq@RsowH!FaFBU{B!@}$M1_D@~{4zKlqk!{PHjO<2OCx z+O>#^F>E^^-I*>7BepB7qD;cSC)x0VYsy6vhGW@E+FZvlv?)YLF+tdL53wyy0e6U5 zm=mhl%}D;whpGUwg5W6W_=ZO=kNUE&d&|4N{VSh6f1dpLe{!Gm@x0>)zj^tM({t~6 z>7V?IH(*r`1LP!P^=CYKI~KNcv*zQg8|k|)wv$elIQ?C$&i4EB-`R_ZfiW(8?;rnz*MHSN{IL)Jg`fPt@3X0iZ}^cPzxwV=Cm;Wo zSAFrd`_t31t<-uO$i?>+#aV_<0G0mgv+>rw<50CXmR3dBA(jx!v63nNp(G_RIVaK6 zBjW5EQlqDHV@{e~kIgS$%~^0ag9G>~7OL2MomjdGFvK;0;-C4fzo7kxPc31}V2gjVgmo2tmQ0$HId-(oYn5^aHs73&Hh$zm8+Pa9;Fs)U;&)*8%#IwY~% zEl7axx#I)6{P;W$weMzPL-VSHQYo=kA+c7mN?iSUu8-XQAwKMyYjNA9^>ZE=H}0pa zob}S}pL5wGuflp#m92?aX~c}?6J(N9Snj?YSU-9K><_@v(%5)U!G5N%vxT1R$cUZ^ zHkSZqSb|%urOP(UR;~bWI5fvZgGV9Ub1Ix9Uh()CSw~ZUz!DK7gQ&#?EA3Q4wOm6P z2j|OS^STn~J{Gcguh7t_*|XY-ntK)ZIoszy)_44pRAPZ~bmn8Q+d(LaW7Mp)@Ub$% zxTvD-ag*z9p&b*NOKr>j%nor@<+^4<7>)h7=geg{&whT@B@rx`V9jpS^@L z?*g=yRQ7J?UcH)jsW)fSi=9xLJ;B+J+i5&M6*DYj_6|YpEFQ^R6NsbT!q>m%#Sgjs z!d*B2#eenIr{C8O>A(C#KlMd7U3twN-}FCz;dOObB`%SM^mQP{Gxk*aOdb{{Yo07l zZiu&v(Xy2s=PMUWO4M+cXBPe;*vn><)XW|^3;jt}Bp~;B$S-w&g=`ZKp|lniDxwqt zZnr2(5nM6MTH%TkvEXTs{Q?9Lciw%v#DI_)IJk~F3w5XktBhdCu0m6BtYHsLmWcFv zJ69@K;IJ-qNZobTD3(^+Yzj+ciKt=knHX!8Vh^4hsQ-M+$GMb(TA0`?bztU-bS8&u zR;-mQA*za1tAL?wV3K#Aym0rRW1QX+F{+en^%}<>4XlJ@ zR#R2lt9r{Ru;^?>gIS1I(_aTfkJ#(B?P3+a*DIfX+vj}SFMRgz#%Jb7-~Q3xQayOxKm1p(`-~@E{jR_M zzkbm}mZM!H&LfV_4UOHmT`qa}W8_ulmuqzv;W) zabNkqzW=A*c-6oDp1=0~M?Cihzw+!Cec@%tP%T4o?r29sG!<=FDq>(7C(SjKyzY#E zWxCp03DgF5bQKPHFIg%&(ByZ?U-5Q@Zt16)f>qER&atIUdVhKxyFo5Tq1}0KH+B)r z>%Qc3uKD`UeeOHH<0pRpFa2)6mOuKg4+Q|c`By)*oyp(&6K{R|xBkdazwb|e!Ds&S zFMHAR%ojdhxZ5onu8ndeObB&PmIP)@SD8rCwc!LZT0}-wSV&+{M6klgi*wmy1GDwU zGF*$3)4!*gJ)^0Wp;CBj>2=&Jz;GRK{|-r@|DXyof(kJtEPd}>uylg=oX!V|{KOrX zkcZ%D77iusGa>D%0;CAKW>d8iSp*J%s(ZsK`t#yB>0Z`dm;7jYa_m6~%+{uC;vvU= z2Mi)Ucz5Zf_9_6UrBE)m6ML?fu%I+t5ZLm~UMFMhs&3U`cf)l%eao9Z{fY-hj{kT6 zyKkAj;@lONt~g4Z3a*WS}kj|0}geo{QucT2^72>>e_GKzeI0!D-tZr8=U0BlwZEk*@Q;7)6a2%^dGPknUiPdT zw*UJZf8cG)t)KYtSAPC89{Wu%ef|@#jS(oIP$~{BM%|-Px9%)Vse3!-nxiWzAL!q9(i-r!l?6m!jP7b{Ebu2jRN6q{>sf_2P`wRS#jRiHUHRG4#qeC92h4Gd}X^FFEBqe&}U4yyA5~@!$GNUHbVSi1W{W#eWe~Jbl{_kK<4OgL&y~KQy9* zjG+QG!mq+*&wAN!8*O~okNh8xx$Dl8e}F3={Mj%3@>c+eTu)E294{Q~DiMSqSnZqX znjnYk3_v%cwZZ~vv&{`jxn^76m)>KFaH-|lsI<1c@N#TsAslt-)p@Wx;H zFxMsp|Iw#De0%-A|6RY~B#a1V@qd2WqyCH6_GM3cL;=7TKlu^$SAO*Wdc-F$-1P%5 zeB2G6@w}TJ=`diX%r~Mvzfg>B7D^&T3aW{r3^=}C~iVWyzmE^(Ns zG^c^(iY^gEP*LQ;tbbeTDp~!zp&C8oz=F!j#OgJ1U<(>+_SB#W%H?_oi5MX$g9{#W z)5Cbn$3LvP;d&HKRqz*jMZ~g}+g(F3YLHADY)LHcTXMysq7QbPun#C(lLW>RwVISK z$nd$W1eX!0ULdCGsw3bd7r@(2?!j)^;q>$bTvW%@8Uq!8=a(3wc*Q zrMwP zV({{TDUps^dfsHph6o?|?cjeDc=7 z@ll~Nc-0R2kM9RAWnTRCS94*;C+}Hx?r2wx9JK}*rL1w*IA}iF*5jFCgu4W_(6fL` zQD>u0_CA-0P*s7^d)jVdGuJ4l0khogk{nvtU|INZ#_X%`ZbG9F<>qqsE7DZ{qA*(U zopwaguaVq#QBhC@huIW`+o>SiZOF#GF3%~YDZ@8@P_~FNY`8T}hcj~)&%TK)F_|^-jH{P>9 z{~BKPDSzbQPkzcnAAI8z04!_OIugNMOwL8%%z17Kyt{!oCH@&~Wn*|+n{!6&-UpHm ztI|LeG$Ys8y5Jlct4^!gS-ZXZF7h^OO!F6$nkCQeA)Btt&ZG@W8R5$9Wbrp{yLkFf z-}dfbe)rAy{AonJ{U5#hbN{jAWYnq4}HX<>AG~iYSKew3? zi(LRqR8^7hxpl=Ke%6h6*yT6iV;4Sw-LkOS$}V4h&PcQk+Exu74q4B9>?8MY{ug(g zfA6n-@>Os8hWCE|17pYiXfV0{72o^R_0olR;yb=dC;x8ce#oVAWjWY8)VawQ;@Sn{ zqHyvLe*t*JHE#D)xxyW5BO`L6R1?y`AaK|`TYb#=`;0En-lCoCV*Are!riw4%Q3+O zuR1Ti^}WPB(&_tOB~ZNOo;@!+KU`x2nMVfM-`6+2E((`vAv7Vtb!PDBF{qOB%xvG~ zJboc?5K2{$9gS2=**+_R6A#dp66R=X2uKKnL^efbTQ&z(DT_7ST6Ar!@U}sit-Czi za83TRZ?s}a=^HO36-Es1Pxm+)u^u07Wb1MBoqPPB@A$+W|Ms09{^*^{gMRVS>FEa$ z@e3>X@z}3-fh;b8g%!KYj>m32xobxpIJjfwU~COTLE(*{4wzO`HSLfRxB@}Zj%>j$p6`ttvnhy91daWlqpslPVIy8)zjN9#UI(4_+dyRod{+whfG zcztqQSC{U&{;o?GpLbe(_LYx*>h+I)(o>Hg{E$zDb~_BBCd_ttxmSL2`lt2Wzz|Yo z%xdp@8F;!27TV+w%+_YiZmWr0KCA7s5anc^hU^ zr>re#e&z-RFV%?Qu^e8 zZrP2oM`WmQ{`Qmgc!xYUcH{0KGOCUNU41G%>|Ei7{dzcm@$~R%*Pc7~m`6Y4y6ZmY zX*XW}=&R3T=@20@3?U^3ZS@@x(#%^#0$4hyS3YPWv`v9id8!b&78Ttr*185mVA{o} zK5p1>>DaPkZO3Ai*EU3@K}oX*%1dU26)k_zsqOc)U1IE~-;=&SX-M?RGwxZ{&L8tfIjq++Hx#`K)| zK+p(|!zpy*4c8t%{?RLszUS}1^G$F0TOWD#17pYiV3qT@@A@F$^EHpu{{8RyyyMUK zEL^w*MTy~IJ%ZTMzCM0Dpm2fmu02r)uOteM?n|G6#>Y>ih`YSjmMalL>I~vJS3uRj z#<0$Sv5w$bWDz?_=bZ*ef3d|)T()2_#TwU*wN+LwrnNbefht5`c=9fY zER{G6h)1kKHpzZN7Fh&03wE@^0YNND3xJI5C`0-j_TITG%aMLgGBs+L^e8gM9Hfd4 zINNvY>zYel6b!Xaj*y}>SL1_7iDQWsS`Med0UX9)J@l%hdd&+Tefeu%_~<8~_D@a` zT7epgl3(VmLaxACJFG063t@>J&Mk!z7J9+Bmy%y=t>C&AvM2-=GS(Q3UXg_zARL>} z6m3h81v$FR2E)E-1%Ne(CB1;_G8|d^^1$3zyG>Z>6p7o#*v4UBM%kblb_;GBm|KqN zS!LG9hvd=Kch!i#q@K0!4q41MuRDnK+*Oz7(Nms!^q{9a^-%;KP2jm%_(P({F|F=4 z$l3MI!cy5Bc!E`^SO5-;;dLO!f)RmX#9#%qdU{(`WEjB{(K-HRY4rTW*^E6PI78K#SO^B}l zB&BRzU6P2QaG{;Gi~cyOkO(jD&@@P*fC_nH=xvyjzAW3Oq;R3C4CkGGta9^il+_W> zIZ4)lh6;9pCki1c!ml-1!SV^hBoI~ZT}?dUx|@I#j6;Q1^*IlW9QTvK