diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart index 74f5bd8..f93fbc3 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart @@ -13,22 +13,29 @@ import '../../marianumconnect_api.dart'; import '../../marianumconnect_endpoint.dart'; import 'telemetry_device_id.dart'; -/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one -/// upsert per app start carrying the stable install id, platform, app version -/// and device info. Bearer-authenticated via the shared dio interceptor. -/// Replaces the legacy mhsl.eu `server/userIndex/update` call. +/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — +/// upserts the stable install id, platform, app version and device info. Sent +/// once on app start and again once push registration completes that session +/// (so a fresh registration isn't under-reported until the next launch). +/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy +/// mhsl.eu `server/userIndex/update` call. class TelemetryHeartbeat { final Dio _dio; TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); /// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed - /// send never disrupts app start. Used from the app shell's initState. - static void report() { - unawaited(TelemetryHeartbeat().send().catchError((Object _) {})); + /// send never disrupts app start. Used from the app shell's initState and + /// re-emitted once push registration completes (see `_MainState._syncPush`). + static void report({required bool notificationsEnabled}) { + unawaited( + TelemetryHeartbeat() + .send(notificationsEnabled: notificationsEnabled) + .catchError((Object _) {}), + ); } - Future send() async { + Future send({required bool notificationsEnabled}) async { try { final info = DeviceInfoPlugin(); final package = await PackageInfo.fromPlatform(); @@ -58,7 +65,12 @@ class TelemetryHeartbeat { MarianumConnectEndpoint.resolve('me/telemetry'), data: { 'deviceIdentifier': deviceIdentifier, + // `pushDeviceIdentifier` reflects a *completed* registration and is + // absent until it lands; `pushEnabled` carries the user's intent + // (the notification toggle) so the backend can tell "user wants push" + // apart from "registration not finished yet". 'pushDeviceIdentifier': ?pushDeviceIdentifier, + 'pushEnabled': notificationsEnabled, 'platform': platform, 'appVersion': package.version, 'appBuild': int.tryParse(package.buildNumber), diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart new file mode 100644 index 0000000..ab63377 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -0,0 +1,68 @@ +import 'dart:developer'; + +import 'package:localstore/localstore.dart'; + +import '../../../../model/account_data.dart'; +import '../../../demo/demo_mode.dart'; +import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; +import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; +import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; +import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; +import 'timetable_custom_events_add.dart'; + +/// One-time migration of the user's custom timetable events from the legacy +/// MHSL backend to Marianum-Connect. Runs at most once per install, guarded by +/// a persisted flag. +/// +/// The MHSL identity is `sha512(username:password)` — computed here exactly as +/// the app always did, so the fetch matches the user's own events perfectly. +/// Each event is POSTed to Marianum-Connect and then deleted from MHSL, which +/// makes the whole run idempotent and resumable: a re-run after a mid-way +/// failure only sees the events that were not yet moved, so nothing is +/// duplicated. The flag is set only once MHSL reports no remaining events. +class CustomEventsMigration { + static const String _collection = 'MarianumMobile'; + static const String _document = 'customEventsMigration'; + static const String _doneKey = 'migratedToMc'; + + const CustomEventsMigration._(); + + static Future runOnce() async { + if (DemoMode.active) return; + if (await _isDone()) return; + + try { + final response = await GetCustomTimetableEvent( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + ).run(); + + for (final event in response.events) { + await TimetableCustomEventsAdd().run(event); + await RemoveCustomTimetableEvent( + RemoveCustomTimetableEventParams(event.id), + ).run(); + } + + await _markDone(); + log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); + } catch (e) { + // Leave the flag unset so the next launch retries; the delete-after-post + // above keeps a partial run duplicate-free. + log('Custom events migration failed, will retry on next launch: $e'); + } + } + + static Future _isDone() async { + final data = await Localstore.instance + .collection(_collection) + .doc(_document) + .get(); + return data != null && data[_doneKey] == true; + } + + static Future _markDone() async { + await Localstore.instance.collection(_collection).doc(_document).set({ + _doneKey: true, + }); + } +} diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_add.dart b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_add.dart new file mode 100644 index 0000000..67541b1 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_add.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; + +import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +class TimetableCustomEventsAdd { + final Dio _dio; + + TimetableCustomEventsAdd({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run(CustomTimetableEvent event) async { + try { + await _dio.post( + MarianumConnectEndpoint.resolve('timetable/custom-events'), + data: event.toJson(), + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart new file mode 100644 index 0000000..b59bc8f --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart @@ -0,0 +1,15 @@ +import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../request_cache.dart'; +import 'timetable_custom_events_get.dart'; + +class TimetableCustomEventsCache + extends SimpleCache { + TimetableCustomEventsCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => TimetableCustomEventsGet().run(), + fromJson: GetCustomTimetableEventResponse.fromJson, + ) { + start('customTimetableEvents'); + } +} diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart new file mode 100644 index 0000000..656e8c6 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart @@ -0,0 +1,23 @@ +import 'package:dio/dio.dart'; + +import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +class TimetableCustomEventsGet { + final Dio _dio; + + TimetableCustomEventsGet({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('timetable/custom-events'), + ); + return GetCustomTimetableEventResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart new file mode 100644 index 0000000..0ee0ef0 --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart @@ -0,0 +1,22 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +class TimetableCustomEventsRemove { + final Dio _dio; + + TimetableCustomEventsRemove({Dio? dio}) + : _dio = dio ?? MarianumConnectApi.dio(); + + Future run(String id) async { + try { + await _dio.delete( + MarianumConnectEndpoint.resolve('timetable/custom-events/$id'), + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart new file mode 100644 index 0000000..b8a315a --- /dev/null +++ b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; + +import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +class TimetableCustomEventsUpdate { + final Dio _dio; + + TimetableCustomEventsUpdate({Dio? dio}) + : _dio = dio ?? MarianumConnectApi.dio(); + + Future run(String id, CustomTimetableEvent event) async { + try { + await _dio.put( + MarianumConnectEndpoint.resolve('timetable/custom-events/$id'), + data: event.toJson(), + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart deleted file mode 100644 index abd6683..0000000 --- a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http/http.dart' as http; - -import '../../mhsl_api.dart'; -import 'add_custom_timetable_event_params.dart'; - -class AddCustomTimetableEvent extends MhslApi { - AddCustomTimetableEventParams params; - - AddCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); - - @override - void assemble(String raw) {} - - @override - Future? request(Uri uri) { - var body = jsonEncode(params.toJson()); - return http.post(uri, body: body); - } -} diff --git a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart deleted file mode 100644 index fab722d..0000000 --- a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../custom_timetable_event.dart'; - -part 'add_custom_timetable_event_params.g.dart'; - -@JsonSerializable(explicitToJson: true) -class AddCustomTimetableEventParams { - String user; - CustomTimetableEvent event; - - AddCustomTimetableEventParams(this.user, this.event); - - factory AddCustomTimetableEventParams.fromJson(Map json) => - _$AddCustomTimetableEventParamsFromJson(json); - Map toJson() => _$AddCustomTimetableEventParamsToJson(this); -} diff --git a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart deleted file mode 100644 index eb39f91..0000000 --- a/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'add_custom_timetable_event_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -AddCustomTimetableEventParams _$AddCustomTimetableEventParamsFromJson( - Map json, -) => AddCustomTimetableEventParams( - json['user'] as String, - CustomTimetableEvent.fromJson(json['event'] as Map), -); - -Map _$AddCustomTimetableEventParamsToJson( - AddCustomTimetableEventParams instance, -) => {'user': instance.user, 'event': instance.event.toJson()}; diff --git a/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart index eb8eab9..f93e657 100644 --- a/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart @@ -1,7 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import '../mhsl_api.dart'; - part 'custom_timetable_event.g.dart'; @JsonSerializable() @@ -9,15 +7,23 @@ class CustomTimetableEvent { String id; String title; String description; - @JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson) + @JsonKey(toJson: _dateToJson, fromJson: _dateFromJson) DateTime startDate; - @JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson) + @JsonKey(toJson: _dateToJson, fromJson: _dateFromJson) DateTime endDate; String? color; String rrule; - @JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson) + + /// When true, occurrences of a recurring event that fall inside a school + /// holiday are hidden in the calendar. Purely client-side: the backend only + /// stores the flag, the exclusion is applied at render time against the + /// holiday list. Ignored for non-recurring events (empty [rrule]). + @JsonKey(defaultValue: false) + bool skipHolidays; + + @JsonKey(toJson: _dateToJson, fromJson: _dateFromJson) DateTime createdAt; - @JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson) + @JsonKey(toJson: _dateToJson, fromJson: _dateFromJson) DateTime updatedAt; CustomTimetableEvent({ @@ -28,6 +34,7 @@ class CustomTimetableEvent { required this.endDate, required this.color, required this.rrule, + this.skipHolidays = false, required this.createdAt, required this.updatedAt, }); @@ -35,4 +42,13 @@ class CustomTimetableEvent { factory CustomTimetableEvent.fromJson(Map json) => _$CustomTimetableEventFromJson(json); Map toJson() => _$CustomTimetableEventToJson(this); + + // Marianum-Connect serializes LocalDateTime as ISO-8601 (`yyyy-MM-ddTHH:mm:ss`) + // and its GSON parser rejects anything else. DateTime.parse still reads the + // old MHSL `yyyy-MM-dd HH:mm:ss` shape too, so events fetched during the + // one-time migration deserialize unchanged. + static DateTime _dateFromJson(String raw) => DateTime.parse(raw); + static String _dateToJson(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}' + 'T${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}:${d.second.toString().padLeft(2, '0')}'; } diff --git a/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart index b83138b..bb4fffa 100644 --- a/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart @@ -12,12 +12,13 @@ CustomTimetableEvent _$CustomTimetableEventFromJson( id: json['id'] as String, title: json['title'] as String, description: json['description'] as String, - startDate: MhslApi.dateTimeFromJson(json['startDate'] as String), - endDate: MhslApi.dateTimeFromJson(json['endDate'] as String), + startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String), + endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String), color: json['color'] as String?, rrule: json['rrule'] as String, - createdAt: MhslApi.dateTimeFromJson(json['createdAt'] as String), - updatedAt: MhslApi.dateTimeFromJson(json['updatedAt'] as String), + skipHolidays: json['skipHolidays'] as bool? ?? false, + createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String), + updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String), ); Map _$CustomTimetableEventToJson( @@ -26,10 +27,11 @@ Map _$CustomTimetableEventToJson( 'id': instance.id, 'title': instance.title, 'description': instance.description, - 'startDate': MhslApi.dateTimeToJson(instance.startDate), - 'endDate': MhslApi.dateTimeToJson(instance.endDate), + 'startDate': CustomTimetableEvent._dateToJson(instance.startDate), + 'endDate': CustomTimetableEvent._dateToJson(instance.endDate), 'color': instance.color, 'rrule': instance.rrule, - 'createdAt': MhslApi.dateTimeToJson(instance.createdAt), - 'updatedAt': MhslApi.dateTimeToJson(instance.updatedAt), + 'skipHolidays': instance.skipHolidays, + 'createdAt': CustomTimetableEvent._dateToJson(instance.createdAt), + 'updatedAt': CustomTimetableEvent._dateToJson(instance.updatedAt), }; diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart deleted file mode 100644 index d644acc..0000000 --- a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart +++ /dev/null @@ -1,20 +0,0 @@ -import '../../../request_cache.dart'; -import 'get_custom_timetable_event.dart'; -import 'get_custom_timetable_event_params.dart'; -import 'get_custom_timetable_event_response.dart'; - -class GetCustomTimetableEventCache - extends SimpleCache { - GetCustomTimetableEventCache( - GetCustomTimetableEventParams params, { - super.onUpdate, - super.onError, - super.renew, - }) : super( - cacheTime: RequestCache.cacheMinute, - loader: () => GetCustomTimetableEvent(params).run(), - fromJson: GetCustomTimetableEventResponse.fromJson, - ) { - start('customTimetableEvents'); - } -} diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart deleted file mode 100644 index ab537e6..0000000 --- a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http/http.dart' as http; - -import '../../mhsl_api.dart'; -import 'update_custom_timetable_event_params.dart'; - -class UpdateCustomTimetableEvent extends MhslApi { - UpdateCustomTimetableEventParams params; - - UpdateCustomTimetableEvent(this.params) - : super('server/timetable/customEvents'); - - @override - void assemble(String raw) {} - - @override - Future? request(Uri uri) => - http.patch(uri, body: jsonEncode(params.toJson())); -} diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart deleted file mode 100644 index f4e16f4..0000000 --- a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../custom_timetable_event.dart'; - -part 'update_custom_timetable_event_params.g.dart'; - -@JsonSerializable(explicitToJson: true) -class UpdateCustomTimetableEventParams { - String id; - CustomTimetableEvent event; - - UpdateCustomTimetableEventParams(this.id, this.event); - - factory UpdateCustomTimetableEventParams.fromJson( - Map json, - ) => _$UpdateCustomTimetableEventParamsFromJson(json); - Map toJson() => - _$UpdateCustomTimetableEventParamsToJson(this); -} diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart deleted file mode 100644 index 37c5d71..0000000 --- a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'update_custom_timetable_event_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -UpdateCustomTimetableEventParams _$UpdateCustomTimetableEventParamsFromJson( - Map json, -) => UpdateCustomTimetableEventParams( - json['id'] as String, - CustomTimetableEvent.fromJson(json['event'] as Map), -); - -Map _$UpdateCustomTimetableEventParamsToJson( - UpdateCustomTimetableEventParams instance, -) => {'id': instance.id, 'event': instance.event.toJson()}; diff --git a/lib/app.dart b/lib/app.dart index 40d1915..7727c36 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -178,22 +178,23 @@ class _AppState extends State with WidgetsBindingObserver { if (mounted) setState(() {}); }); - TelemetryHeartbeat.report(); + TelemetryHeartbeat.report( + notificationsEnabled: + context.read().val().notificationSettings.enabled, + ); // A refreshed FCM token invalidates the existing push subscription — the // NC device identifier stays stable, so we simply re-register (NC first, // then the proxy). Debounced so a burst of refreshes triggers one call. - if (context.read().val().notificationSettings.enabled) { - _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(( - _, - ) { - Debouncer.debounce( - 'pushTokenRefresh', - const Duration(seconds: 3), - () => unawaited(PushRegistration().onTokenRefresh()), - ); - }); - } + // Not gated on the notification toggle: registration is kept alive even + // when notifications are off so silent sync pushes keep flowing. + _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((_) { + Debouncer.debounce( + 'pushTokenRefresh', + const Duration(seconds: 3), + () => unawaited(PushRegistration().onTokenRefresh()), + ); + }); // Android renders pushes locally, so a tap arrives via the local // notifications callback (PushTapRouter) rather than onMessageOpenedApp. diff --git a/lib/main.dart b/lib/main.dart index 2b616df..a1641c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; +import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; @@ -29,6 +30,7 @@ import 'model/account_data.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; import 'push/push_registration.dart'; +import 'push/push_registration_store.dart'; import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; @@ -251,14 +253,23 @@ class _MainState extends State
{ unawaited(ListFilesCache.prefetchRootListing()); } - /// Registers/self-heals the push subscription when push is user-enabled and - /// the backend advertises the capability. Fire-and-forget. + /// Registers/self-heals the push subscription whenever the backend advertises + /// the capability — independent of the notification toggle, so a user with + /// notifications off stays registered for silent sync pushes. Fire-and-forget. void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) { + final enabled = settings.val().notificationSettings.enabled; unawaited( PushRegistration.syncSubscription( - enabled: settings.val().notificationSettings.enabled, capable: capabilities.canReceivePushNotifications, - ), + ).then((registered) { + // The app-start heartbeat runs before this async registration + // finishes, so it reports the pre-registration state. Re-emit once + // the identifier is persisted so the new push status shows up this + // session instead of only after the next launch. + if (registered) { + TelemetryHeartbeat.report(notificationsEnabled: enabled); + } + }), ); } @@ -290,6 +301,13 @@ class _MainState extends State
{ final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl(); MarianumConnectEndpoint.update(mcBaseUrl); unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl)); + // Mirror the notification toggle into group-scoped storage so the FCM + // background isolate and the iOS NSE can suppress rendering when off. + unawaited( + const PushRegistrationStore().setNotificationsEnabled( + settings.notificationSettings.enabled, + ), + ); return MaterialApp( showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, checkerboardOffscreenLayers: diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index fd1e8b5..b72145c 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -77,15 +77,24 @@ class PushMessageHandler { String? openChatToken, }) async { final data = message.data; + // The device stays registered even when the user turns notifications off, + // so silent sync pushes (deletes, data refresh) keep arriving. When off we + // still process the message but skip raising a visible notification. + final notificationsEnabled = await _registrationStore.notificationsEnabled(); switch (classifyPush(data)) { case PushKind.connect: - await _handleConnect(message, foreground: foreground); + await _handleConnect( + message, + foreground: foreground, + notificationsEnabled: notificationsEnabled, + ); break; case PushKind.nextcloud: await _handleNextcloud( data, foreground: foreground, openChatToken: openChatToken, + notificationsEnabled: notificationsEnabled, ); break; case PushKind.unknown: @@ -96,7 +105,11 @@ class PushMessageHandler { Future _handleConnect( RemoteMessage message, { required bool foreground, + required bool notificationsEnabled, }) async { + // Connect pushes carry no silent side effects, so nothing to do when the + // user has notifications off. + if (!notificationsEnabled) return; // On iOS the alert is delivered natively by the system; only Android needs // to render the plaintext payload locally. final data = message.data; @@ -114,6 +127,7 @@ class PushMessageHandler { Map data, { required bool foreground, required String? openChatToken, + required bool notificationsEnabled, }) async { final subjectBase64 = data['subject'] as String; final signatureBase64 = data['signature'] as String; @@ -153,6 +167,11 @@ class PushMessageHandler { return; } + // Notifications turned off: the push was still processed (deletes above, + // plus the foreground badge/provider refresh in NotificationController) — + // only the visible tray notification is suppressed. + if (!notificationsEnabled) return; + await _renderer.render(subject); } diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 4c98d7a..1d2556d 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -350,21 +350,26 @@ class PushRegistration { } } - /// Registers this device when push is both user-enabled and backend-capable. - /// Only registers when the OS notification permission is *already* granted — - /// it never triggers the OS prompt itself. Requesting the permission is the - /// job of the first Talk visit (see `maybePromptTalkNotifications`), which - /// keeps the prompt out of the cold-start path. Safe to call on every start — - /// Nextcloud dedups an unchanged registration — which also self-heals a - /// device whose registration was lost. - static Future syncSubscription({ - required bool enabled, - required bool capable, - }) async { - if (!(enabled && capable)) return; + /// Registers this device whenever the backend advertises the push capability. + /// Deliberately independent of the in-app notification toggle: a user who + /// turned notifications off stays registered so silent sync pushes keep + /// flowing — the display is suppressed downstream via the mirrored flag (see + /// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS + /// notification permission is *already* granted — it never triggers the OS + /// prompt itself. Requesting the permission is the job of the first Talk visit + /// (see `maybePromptTalkNotifications`), which keeps the prompt out of the + /// cold-start path. Safe to call on every start — Nextcloud dedups an + /// unchanged registration — which also self-heals a device whose registration + /// was lost. + /// Returns whether registration was actually *attempted* (all gates passed). + /// Even a partial success persists the `general` device identifier, so the + /// caller re-emits telemetry on `true` to reflect the fresh registration in + /// the same session instead of lagging until the next launch. + static Future syncSubscription({required bool capable}) async { + if (!capable) return false; if (!await isOsPermissionGranted()) { log('Push: OS notification permission not granted, skipping registration'); - return; + return false; } final registration = PushRegistration(); // register() below refreshes an unchanged subscription anyway; the check @@ -373,6 +378,7 @@ class PushRegistration { log('Push: registered endpoints outdated, re-registering'); } await registration.register(); + return true; } /// Re-registers after an FCM token refresh. The Nextcloud device identifier diff --git a/lib/push/push_registration_store.dart b/lib/push/push_registration_store.dart index 4ed05f0..e5f3a18 100644 --- a/lib/push/push_registration_store.dart +++ b/lib/push/push_registration_store.dart @@ -27,6 +27,11 @@ class PushRegistrationStore { // (AccountData writes `nextcloud_app_password` group-scoped). static const _usernameKey = 'nextcloud_username'; static const _baseUrlKey = 'nextcloud_base_url'; + // Mirror of the in-app notification toggle (`notificationSettings.enabled`), + // written group-scoped so the FCM background isolate (no bloc access) and the + // iOS NSE can gate rendering. The device stays *registered* when off so silent + // sync pushes keep flowing — only the visible alert is suppressed. + static const _notificationsEnabledKey = 'push_notifications_enabled'; static const _perTypeKeys = [ _deviceIdentifierKey, @@ -82,6 +87,19 @@ class PushRegistrationStore { await _storage.write(key: _baseUrlKey, value: baseUrl); } + /// Mirrors the in-app notification toggle so the background isolate / iOS NSE + /// can read it without bloc access. + Future setNotificationsEnabled(bool enabled) => + _storage.write( + key: _notificationsEnabledKey, + value: enabled ? '1' : '0', + ); + + /// The mirrored notification toggle. Defaults to `true` when unset (fresh + /// install / pre-mirror build) so a missing mirror never silences pushes. + Future notificationsEnabled() async => + await _storage.read(key: _notificationsEnabledKey) != '0'; + Future deviceIdentifier(PushRegistrationType type) => _storage.read(key: keyFor(_deviceIdentifierKey, type)); diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index d152404..a9c848c 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:intl/intl.dart'; +import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../extensions/date_time.dart'; @@ -187,6 +188,7 @@ class TimetableBloc bool renew = false, }) async { try { + await CustomEventsMigration.runOnce(); final events = await repo.data.getCustomEvents( renew: renew, onError: onError, diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index 77287b4..2ada19f 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -1,5 +1,9 @@ import '../../../../../api/demo/data/demo_timetable.dart'; import '../../../../../api/demo/demo_mode.dart'; +import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_add.dart'; +import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart'; +import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart'; +import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; @@ -12,22 +16,13 @@ import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timeta import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; -import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart'; -import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; -import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart'; -import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; -import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; -import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; -import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; -import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; import '../../../../../api/request_cache.dart'; -import '../../../../../model/account_data.dart'; /// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is /// its own HTTP call; this provider exposes the lazy futures so the bloc can -/// chain them without seeing the dio layer. Custom events still come from MHSL. +/// chain them without seeing the dio layer. class TimetableDataProvider { Future getWeek( DateTime startDate, @@ -100,12 +95,11 @@ class TimetableDataProvider { }) { if (DemoMode.active) return Future.value(DemoTimetable.customEvents()); return resolveFromCache( - (onUpdate, onError) => GetCustomTimetableEventCache( - GetCustomTimetableEventParams(AccountData().getUserSecret()), - renew: renew, - onUpdate: onUpdate, - onError: onError, - ), + (onUpdate, onError) => TimetableCustomEventsCache( + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), onError: onError, operationName: 'getCustomEvents', ); @@ -113,20 +107,16 @@ class TimetableDataProvider { Future addCustomEvent(CustomTimetableEvent event) { if (DemoMode.active) return Future.value(); - return AddCustomTimetableEvent( - AddCustomTimetableEventParams(AccountData().getUserSecret(), event), - ).run(); + return TimetableCustomEventsAdd().run(event); } Future updateCustomEvent(String id, CustomTimetableEvent event) { if (DemoMode.active) return Future.value(); - return UpdateCustomTimetableEvent( - UpdateCustomTimetableEventParams(id, event), - ).run(); + return TimetableCustomEventsUpdate().run(id, event); } Future removeCustomEvent(String id) { if (DemoMode.active) return Future.value(); - return RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); + return TimetableCustomEventsRemove().run(id); } } diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index d3c51e6..50b50df 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -63,6 +63,10 @@ class TalkSection extends StatelessWidget { Haptics.selection(); final enabled = e ?? false; settings.val(write: true).notificationSettings.enabled = enabled; + // Turning off does NOT unregister: the device stays subscribed so + // silent sync pushes keep arriving; the message handler and iOS + // NSE suppress only the visible notification (via the mirrored + // flag). Enabling (re-)registers and ensures the OS permission. if (enabled) { final messenger = ScaffoldMessenger.of(context); unawaited(() async { @@ -82,8 +86,6 @@ class TalkSection extends StatelessWidget { ); } }()); - } else { - unawaited(PushRegistration().unregister()); } }, ), diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index cd401d9..544293d 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -57,6 +57,10 @@ class _CustomEventEditDialogState extends State { text: widget.existingEvent?.description ?? widget.initialDescription, ); late String _rrule = widget.existingEvent?.rrule ?? ''; + // Für neue Termine standardmäßig an, bestehende behalten ihren Wert. Migrierte + // mhsl-Termine tragen ausgeschaltet (Modell-Default), da sie nie ein Serien- + // Ferien-Flag hatten. + late bool _skipHolidays = widget.existingEvent?.skipHolidays ?? true; late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere( (e) => e.name == widget.existingEvent?.color, orElse: () => TimetableColors.defaultColor, @@ -150,6 +154,7 @@ class _CustomEventEditDialogState extends State { endDate: endDate, color: _color.name, rrule: _rrule, + skipHolidays: _rrule.trim().isEmpty ? false : _skipHolidays, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); @@ -297,6 +302,18 @@ class _CustomEventEditDialogState extends State { }, ), ), + if (_rrule.trim().isNotEmpty) ...[ + const Divider(), + SwitchListTile( + secondary: const Icon(Icons.beach_access_outlined), + title: const Text('In den Ferien ausblenden'), + subtitle: const Text( + 'An Ferientagen wird der Termin nicht angezeigt', + ), + value: _skipHolidays, + onChanged: (v) => setState(() => _skipHolidays = v), + ), + ], ], ), ), diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 744d1aa..baccbb4 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -1,5 +1,6 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../../api/marianumconnect/models/mc_holiday.dart'; import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; @@ -16,6 +17,7 @@ class TimetableAppointmentFactory { final List lessons; final List customEvents; final List subjects; + final List holidays; final TimetableSettings settings; final DateTime now; @@ -25,6 +27,7 @@ class TimetableAppointmentFactory { required this.subjects, required this.settings, required this.now, + this.holidays = const [], }); List build() { @@ -98,6 +101,14 @@ class TimetableAppointmentFactory { ), ) .toList(); + // Ferien ausblenden ist nur für Serien sinnvoll; Syncfusion matcht + // Ausnahmen über recurrenceExceptionDates. Überzählige Ferientage, an denen + // gar keine Occurrence liegt, ignoriert es folgenlos — daher genügt es, für + // jeden Ferientag ein Ausnahmedatum zur Terminzeit einzustreuen, ohne die + // RRULE selbst expandieren zu müssen. + if (event.skipHolidays && parsed.rule.isNotEmpty) { + exceptionDates.addAll(_holidayExceptionDates(event)); + } return Appointment( id: CustomAppointment(event), startTime: event.startDate, @@ -128,6 +139,36 @@ class TimetableAppointmentFactory { ); } + List _holidayExceptionDates(CustomTimetableEvent event) { + final result = []; + for (final holiday in holidays) { + var day = DateTime( + holiday.startDate.year, + holiday.startDate.month, + holiday.startDate.day, + ); + final last = DateTime( + holiday.endDate.year, + holiday.endDate.month, + holiday.endDate.day, + ); + while (!day.isAfter(last)) { + result.add( + DateTime( + day.year, + day.month, + day.day, + event.startDate.hour, + event.startDate.minute, + event.startDate.second, + ), + ); + day = DateTime(day.year, day.month, day.day + 1); + } + } + return result; + } + /// All-day convention: a `CustomTimetableEvent` is treated as all-day when /// its `startDate` and `endDate` both land on midnight of the same day. /// Keeps the backend schema unchanged — the editor stores all-day events as diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index 093e996..8b8f4db 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -79,6 +79,7 @@ class TimetableCalendarViewState extends State { lessons: state.getAllKnownLessons().toList(), customEvents: widget.customEvents, subjects: state.subjects?.result ?? const [], + holidays: state.schoolHolidays?.result ?? const [], settings: timetableSettings, now: DateTime.now(), ).build(); diff --git a/test/view/timetable/holiday_exclusion_test.dart b/test/view/timetable/holiday_exclusion_test.dart new file mode 100644 index 0000000..caa5cb7 --- /dev/null +++ b/test/view/timetable/holiday_exclusion_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/models/mc_holiday.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import 'package:marianum_mobile/storage/timetable_settings.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/timetable_appointment_factory.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/timetable_name_mode.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +CustomTimetableEvent _recurringEvent({required bool skipHolidays}) => + CustomTimetableEvent( + id: 'e1', + title: 'Sprechstunde', + description: '', + startDate: DateTime(2026, 5, 4, 8, 0), + endDate: DateTime(2026, 5, 4, 9, 0), + color: 'orange', + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', + skipHolidays: skipHolidays, + createdAt: DateTime(2026, 5, 4), + updatedAt: DateTime(2026, 5, 4), + ); + +final _holidays = [ + McHoliday( + shortName: 'Pfingsten', + longName: 'Pfingstferien', + startDate: DateTime(2026, 5, 6), + endDate: DateTime(2026, 5, 8), + ), +]; + +final _settings = TimetableSettings( + connectDoubleLessons: false, + timetableNameMode: TimetableNameMode.name, +); + +Appointment _customAppointment(List appointments) => + appointments.firstWhere((a) => a.id is CustomAppointment); + +void main() { + group('holiday exclusion', () { + test('adds every holiday day at the event time as a recurrence exception ' + 'when skipHolidays is on', () { + final appointments = TimetableAppointmentFactory( + lessons: const [], + customEvents: [_recurringEvent(skipHolidays: true)], + subjects: const [], + holidays: _holidays, + settings: _settings, + now: DateTime(2026, 5, 4), + ).build(); + + final exceptions = _customAppointment(appointments).recurrenceExceptionDates!; + expect(exceptions, contains(DateTime(2026, 5, 6, 8, 0))); + expect(exceptions, contains(DateTime(2026, 5, 7, 8, 0))); + expect(exceptions, contains(DateTime(2026, 5, 8, 8, 0))); + expect(exceptions, hasLength(3)); + }); + + test('leaves recurrence exceptions empty when skipHolidays is off', () { + final appointments = TimetableAppointmentFactory( + lessons: const [], + customEvents: [_recurringEvent(skipHolidays: false)], + subjects: const [], + holidays: _holidays, + settings: _settings, + now: DateTime(2026, 5, 4), + ).build(); + + expect( + _customAppointment(appointments).recurrenceExceptionDates, + isNull, + ); + }); + }); +}