migrated custom timetable events to Marianum-Connect and refactored push notification handling

This commit is contained in:
2026-07-12 14:31:48 +02:00
parent c444ed54a5
commit 91a6216f66
28 changed files with 474 additions and 212 deletions
@@ -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<void> send() async {
Future<void> 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),
@@ -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<void> 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<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
}
@@ -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<void> run(CustomTimetableEvent event) async {
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('timetable/custom-events'),
data: event.toJson(),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<GetCustomTimetableEventResponse> {
TimetableCustomEventsCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheMinute,
loader: () => TimetableCustomEventsGet().run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -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<GetCustomTimetableEventResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/custom-events'),
);
return GetCustomTimetableEventResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<void> run(String id) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('timetable/custom-events/$id'),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<void> run(String id, CustomTimetableEvent event) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('timetable/custom-events/$id'),
data: event.toJson(),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<void> {
AddCustomTimetableEventParams params;
AddCustomTimetableEvent(this.params) : super('server/timetable/customEvents');
@override
void assemble(String raw) {}
@override
Future<Response>? request(Uri uri) {
var body = jsonEncode(params.toJson());
return http.post(uri, body: body);
}
}
@@ -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<String, dynamic> json) =>
_$AddCustomTimetableEventParamsFromJson(json);
Map<String, dynamic> toJson() => _$AddCustomTimetableEventParamsToJson(this);
}
@@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'add_custom_timetable_event_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AddCustomTimetableEventParams _$AddCustomTimetableEventParamsFromJson(
Map<String, dynamic> json,
) => AddCustomTimetableEventParams(
json['user'] as String,
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AddCustomTimetableEventParamsToJson(
AddCustomTimetableEventParams instance,
) => <String, dynamic>{'user': instance.user, 'event': instance.event.toJson()};
@@ -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<String, dynamic> json) =>
_$CustomTimetableEventFromJson(json);
Map<String, dynamic> 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')}';
}
@@ -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<String, dynamic> _$CustomTimetableEventToJson(
@@ -26,10 +27,11 @@ Map<String, dynamic> _$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),
};
@@ -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<GetCustomTimetableEventResponse> {
GetCustomTimetableEventCache(
GetCustomTimetableEventParams params, {
super.onUpdate,
super.onError,
super.renew,
}) : super(
cacheTime: RequestCache.cacheMinute,
loader: () => GetCustomTimetableEvent(params).run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -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<void> {
UpdateCustomTimetableEventParams params;
UpdateCustomTimetableEvent(this.params)
: super('server/timetable/customEvents');
@override
void assemble(String raw) {}
@override
Future<Response>? request(Uri uri) =>
http.patch(uri, body: jsonEncode(params.toJson()));
}
@@ -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<String, dynamic> json,
) => _$UpdateCustomTimetableEventParamsFromJson(json);
Map<String, dynamic> toJson() =>
_$UpdateCustomTimetableEventParamsToJson(this);
}
@@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'update_custom_timetable_event_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UpdateCustomTimetableEventParams _$UpdateCustomTimetableEventParamsFromJson(
Map<String, dynamic> json,
) => UpdateCustomTimetableEventParams(
json['id'] as String,
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$UpdateCustomTimetableEventParamsToJson(
UpdateCustomTimetableEventParams instance,
) => <String, dynamic>{'id': instance.id, 'event': instance.event.toJson()};
+13 -12
View File
@@ -178,22 +178,23 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (mounted) setState(() {});
});
TelemetryHeartbeat.report();
TelemetryHeartbeat.report(
notificationsEnabled:
context.read<SettingsCubit>().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<SettingsCubit>().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.
+22 -4
View File
@@ -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<Main> {
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<Main> {
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:
+20 -1
View File
@@ -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<void> _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<String, dynamic> 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);
}
+19 -13
View File
@@ -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<void> 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<bool> 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
+18
View File
@@ -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<void> 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<bool> notificationsEnabled() async =>
await _storage.read(key: _notificationsEnabledKey) != '0';
Future<String?> deviceIdentifier(PushRegistrationType type) =>
_storage.read(key: keyFor(_deviceIdentifierKey, type));
@@ -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,
@@ -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<TimetableGetWeekResponse> getWeek(
DateTime startDate,
@@ -100,12 +95,11 @@ class TimetableDataProvider {
}) {
if (DemoMode.active) return Future.value(DemoTimetable.customEvents());
return resolveFromCache<GetCustomTimetableEventResponse>(
(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<void> addCustomEvent(CustomTimetableEvent event) {
if (DemoMode.active) return Future.value();
return AddCustomTimetableEvent(
AddCustomTimetableEventParams(AccountData().getUserSecret(), event),
).run();
return TimetableCustomEventsAdd().run(event);
}
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) {
if (DemoMode.active) return Future.value();
return UpdateCustomTimetableEvent(
UpdateCustomTimetableEventParams(id, event),
).run();
return TimetableCustomEventsUpdate().run(id, event);
}
Future<void> removeCustomEvent(String id) {
if (DemoMode.active) return Future.value();
return RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run();
return TimetableCustomEventsRemove().run(id);
}
}
@@ -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());
}
},
),
@@ -57,6 +57,10 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
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<CustomEventEditDialog> {
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<CustomEventEditDialog> {
},
),
),
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),
),
],
],
),
),
@@ -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<McTimetableEntry> lessons;
final List<CustomTimetableEvent> customEvents;
final List<McSubject> subjects;
final List<McHoliday> 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<Appointment> 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<DateTime> _holidayExceptionDates(CustomTimetableEvent event) {
final result = <DateTime>[];
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
@@ -79,6 +79,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
lessons: state.getAllKnownLessons().toList(),
customEvents: widget.customEvents,
subjects: state.subjects?.result ?? const [],
holidays: state.schoolHolidays?.result ?? const [],
settings: timetableSettings,
now: DateTime.now(),
).build();
@@ -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<Appointment> 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,
);
});
});
}