migrated custom timetable events to Marianum-Connect and refactored push notification handling
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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');
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
-18
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user