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 '../../marianumconnect_endpoint.dart';
|
||||||
import 'telemetry_device_id.dart';
|
import 'telemetry_device_id.dart';
|
||||||
|
|
||||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one
|
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||||
/// upsert per app start carrying the stable install id, platform, app version
|
/// upserts the stable install id, platform, app version and device info. Sent
|
||||||
/// and device info. Bearer-authenticated via the shared dio interceptor.
|
/// once on app start and again once push registration completes that session
|
||||||
/// Replaces the legacy mhsl.eu `server/userIndex/update` call.
|
/// (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 {
|
class TelemetryHeartbeat {
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
/// 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.
|
/// send never disrupts app start. Used from the app shell's initState and
|
||||||
static void report() {
|
/// re-emitted once push registration completes (see `_MainState._syncPush`).
|
||||||
unawaited(TelemetryHeartbeat().send().catchError((Object _) {}));
|
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 {
|
try {
|
||||||
final info = DeviceInfoPlugin();
|
final info = DeviceInfoPlugin();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
@@ -58,7 +65,12 @@ class TelemetryHeartbeat {
|
|||||||
MarianumConnectEndpoint.resolve('me/telemetry'),
|
MarianumConnectEndpoint.resolve('me/telemetry'),
|
||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'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,
|
'pushDeviceIdentifier': ?pushDeviceIdentifier,
|
||||||
|
'pushEnabled': notificationsEnabled,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'appVersion': package.version,
|
'appVersion': package.version,
|
||||||
'appBuild': int.tryParse(package.buildNumber),
|
'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 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../mhsl_api.dart';
|
|
||||||
|
|
||||||
part 'custom_timetable_event.g.dart';
|
part 'custom_timetable_event.g.dart';
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
@@ -9,15 +7,23 @@ class CustomTimetableEvent {
|
|||||||
String id;
|
String id;
|
||||||
String title;
|
String title;
|
||||||
String description;
|
String description;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime startDate;
|
DateTime startDate;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime endDate;
|
DateTime endDate;
|
||||||
String? color;
|
String? color;
|
||||||
String rrule;
|
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;
|
DateTime createdAt;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
|
||||||
CustomTimetableEvent({
|
CustomTimetableEvent({
|
||||||
@@ -28,6 +34,7 @@ class CustomTimetableEvent {
|
|||||||
required this.endDate,
|
required this.endDate,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.rrule,
|
required this.rrule,
|
||||||
|
this.skipHolidays = false,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -35,4 +42,13 @@ class CustomTimetableEvent {
|
|||||||
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
|
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
_$CustomTimetableEventFromJson(json);
|
_$CustomTimetableEventFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$CustomTimetableEventToJson(this);
|
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,
|
id: json['id'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
description: json['description'] as String,
|
description: json['description'] as String,
|
||||||
startDate: MhslApi.dateTimeFromJson(json['startDate'] as String),
|
startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String),
|
||||||
endDate: MhslApi.dateTimeFromJson(json['endDate'] as String),
|
endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String),
|
||||||
color: json['color'] as String?,
|
color: json['color'] as String?,
|
||||||
rrule: json['rrule'] as String,
|
rrule: json['rrule'] as String,
|
||||||
createdAt: MhslApi.dateTimeFromJson(json['createdAt'] as String),
|
skipHolidays: json['skipHolidays'] as bool? ?? false,
|
||||||
updatedAt: MhslApi.dateTimeFromJson(json['updatedAt'] as String),
|
createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String),
|
||||||
|
updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CustomTimetableEventToJson(
|
Map<String, dynamic> _$CustomTimetableEventToJson(
|
||||||
@@ -26,10 +27,11 @@ Map<String, dynamic> _$CustomTimetableEventToJson(
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'title': instance.title,
|
'title': instance.title,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'startDate': MhslApi.dateTimeToJson(instance.startDate),
|
'startDate': CustomTimetableEvent._dateToJson(instance.startDate),
|
||||||
'endDate': MhslApi.dateTimeToJson(instance.endDate),
|
'endDate': CustomTimetableEvent._dateToJson(instance.endDate),
|
||||||
'color': instance.color,
|
'color': instance.color,
|
||||||
'rrule': instance.rrule,
|
'rrule': instance.rrule,
|
||||||
'createdAt': MhslApi.dateTimeToJson(instance.createdAt),
|
'skipHolidays': instance.skipHolidays,
|
||||||
'updatedAt': MhslApi.dateTimeToJson(instance.updatedAt),
|
'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()};
|
|
||||||
+7
-6
@@ -178,22 +178,23 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
TelemetryHeartbeat.report();
|
TelemetryHeartbeat.report(
|
||||||
|
notificationsEnabled:
|
||||||
|
context.read<SettingsCubit>().val().notificationSettings.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
// A refreshed FCM token invalidates the existing push subscription — the
|
// A refreshed FCM token invalidates the existing push subscription — the
|
||||||
// NC device identifier stays stable, so we simply re-register (NC first,
|
// NC device identifier stays stable, so we simply re-register (NC first,
|
||||||
// then the proxy). Debounced so a burst of refreshes triggers one call.
|
// then the proxy). Debounced so a burst of refreshes triggers one call.
|
||||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
// Not gated on the notification toggle: registration is kept alive even
|
||||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
|
// when notifications are off so silent sync pushes keep flowing.
|
||||||
_,
|
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((_) {
|
||||||
) {
|
|
||||||
Debouncer.debounce(
|
Debouncer.debounce(
|
||||||
'pushTokenRefresh',
|
'pushTokenRefresh',
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() => unawaited(PushRegistration().onTokenRefresh()),
|
() => unawaited(PushRegistration().onTokenRefresh()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Android renders pushes locally, so a tap arrives via the local
|
// Android renders pushes locally, so a tap arrives via the local
|
||||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
// 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/marianumconnect_endpoint.dart';
|
||||||
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.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/report_client_error/client_error_reporter.dart';
|
||||||
|
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
@@ -29,6 +30,7 @@ import 'model/account_data.dart';
|
|||||||
import 'notification/notification_service.dart';
|
import 'notification/notification_service.dart';
|
||||||
import 'push/push_message_handler.dart';
|
import 'push/push_message_handler.dart';
|
||||||
import 'push/push_registration.dart';
|
import 'push/push_registration.dart';
|
||||||
|
import 'push/push_registration_store.dart';
|
||||||
import 'push/push_renderer.dart';
|
import 'push/push_renderer.dart';
|
||||||
import 'routing/app_routes.dart';
|
import 'routing/app_routes.dart';
|
||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
@@ -251,14 +253,23 @@ class _MainState extends State<Main> {
|
|||||||
unawaited(ListFilesCache.prefetchRootListing());
|
unawaited(ListFilesCache.prefetchRootListing());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers/self-heals the push subscription when push is user-enabled and
|
/// Registers/self-heals the push subscription whenever the backend advertises
|
||||||
/// the backend advertises the capability. Fire-and-forget.
|
/// 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) {
|
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
|
||||||
|
final enabled = settings.val().notificationSettings.enabled;
|
||||||
unawaited(
|
unawaited(
|
||||||
PushRegistration.syncSubscription(
|
PushRegistration.syncSubscription(
|
||||||
enabled: settings.val().notificationSettings.enabled,
|
|
||||||
capable: capabilities.canReceivePushNotifications,
|
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();
|
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
|
||||||
MarianumConnectEndpoint.update(mcBaseUrl);
|
MarianumConnectEndpoint.update(mcBaseUrl);
|
||||||
unawaited(WidgetSync.setMarianumConnectBaseUrl(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(
|
return MaterialApp(
|
||||||
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
||||||
checkerboardOffscreenLayers:
|
checkerboardOffscreenLayers:
|
||||||
|
|||||||
@@ -77,15 +77,24 @@ class PushMessageHandler {
|
|||||||
String? openChatToken,
|
String? openChatToken,
|
||||||
}) async {
|
}) async {
|
||||||
final data = message.data;
|
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)) {
|
switch (classifyPush(data)) {
|
||||||
case PushKind.connect:
|
case PushKind.connect:
|
||||||
await _handleConnect(message, foreground: foreground);
|
await _handleConnect(
|
||||||
|
message,
|
||||||
|
foreground: foreground,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case PushKind.nextcloud:
|
case PushKind.nextcloud:
|
||||||
await _handleNextcloud(
|
await _handleNextcloud(
|
||||||
data,
|
data,
|
||||||
foreground: foreground,
|
foreground: foreground,
|
||||||
openChatToken: openChatToken,
|
openChatToken: openChatToken,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case PushKind.unknown:
|
case PushKind.unknown:
|
||||||
@@ -96,7 +105,11 @@ class PushMessageHandler {
|
|||||||
Future<void> _handleConnect(
|
Future<void> _handleConnect(
|
||||||
RemoteMessage message, {
|
RemoteMessage message, {
|
||||||
required bool foreground,
|
required bool foreground,
|
||||||
|
required bool notificationsEnabled,
|
||||||
}) async {
|
}) 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
|
// On iOS the alert is delivered natively by the system; only Android needs
|
||||||
// to render the plaintext payload locally.
|
// to render the plaintext payload locally.
|
||||||
final data = message.data;
|
final data = message.data;
|
||||||
@@ -114,6 +127,7 @@ class PushMessageHandler {
|
|||||||
Map<String, dynamic> data, {
|
Map<String, dynamic> data, {
|
||||||
required bool foreground,
|
required bool foreground,
|
||||||
required String? openChatToken,
|
required String? openChatToken,
|
||||||
|
required bool notificationsEnabled,
|
||||||
}) async {
|
}) async {
|
||||||
final subjectBase64 = data['subject'] as String;
|
final subjectBase64 = data['subject'] as String;
|
||||||
final signatureBase64 = data['signature'] as String;
|
final signatureBase64 = data['signature'] as String;
|
||||||
@@ -153,6 +167,11 @@ class PushMessageHandler {
|
|||||||
return;
|
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);
|
await _renderer.render(subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,21 +350,26 @@ class PushRegistration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers this device when push is both user-enabled and backend-capable.
|
/// Registers this device whenever the backend advertises the push capability.
|
||||||
/// Only registers when the OS notification permission is *already* granted —
|
/// Deliberately independent of the in-app notification toggle: a user who
|
||||||
/// it never triggers the OS prompt itself. Requesting the permission is the
|
/// turned notifications off stays registered so silent sync pushes keep
|
||||||
/// job of the first Talk visit (see `maybePromptTalkNotifications`), which
|
/// flowing — the display is suppressed downstream via the mirrored flag (see
|
||||||
/// keeps the prompt out of the cold-start path. Safe to call on every start —
|
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
|
||||||
/// Nextcloud dedups an unchanged registration — which also self-heals a
|
/// notification permission is *already* granted — it never triggers the OS
|
||||||
/// device whose registration was lost.
|
/// prompt itself. Requesting the permission is the job of the first Talk visit
|
||||||
static Future<void> syncSubscription({
|
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
|
||||||
required bool enabled,
|
/// cold-start path. Safe to call on every start — Nextcloud dedups an
|
||||||
required bool capable,
|
/// unchanged registration — which also self-heals a device whose registration
|
||||||
}) async {
|
/// was lost.
|
||||||
if (!(enabled && capable)) return;
|
/// 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()) {
|
if (!await isOsPermissionGranted()) {
|
||||||
log('Push: OS notification permission not granted, skipping registration');
|
log('Push: OS notification permission not granted, skipping registration');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
final registration = PushRegistration();
|
final registration = PushRegistration();
|
||||||
// register() below refreshes an unchanged subscription anyway; the check
|
// register() below refreshes an unchanged subscription anyway; the check
|
||||||
@@ -373,6 +378,7 @@ class PushRegistration {
|
|||||||
log('Push: registered endpoints outdated, re-registering');
|
log('Push: registered endpoints outdated, re-registering');
|
||||||
}
|
}
|
||||||
await registration.register();
|
await registration.register();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
|
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class PushRegistrationStore {
|
|||||||
// (AccountData writes `nextcloud_app_password` group-scoped).
|
// (AccountData writes `nextcloud_app_password` group-scoped).
|
||||||
static const _usernameKey = 'nextcloud_username';
|
static const _usernameKey = 'nextcloud_username';
|
||||||
static const _baseUrlKey = 'nextcloud_base_url';
|
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 = [
|
static const _perTypeKeys = [
|
||||||
_deviceIdentifierKey,
|
_deviceIdentifierKey,
|
||||||
@@ -82,6 +87,19 @@ class PushRegistrationStore {
|
|||||||
await _storage.write(key: _baseUrlKey, value: baseUrl);
|
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) =>
|
Future<String?> deviceIdentifier(PushRegistrationType type) =>
|
||||||
_storage.read(key: keyFor(_deviceIdentifierKey, type));
|
_storage.read(key: keyFor(_deviceIdentifierKey, type));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:developer';
|
|||||||
|
|
||||||
import 'package:intl/intl.dart';
|
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/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../../../../extensions/date_time.dart';
|
import '../../../../../extensions/date_time.dart';
|
||||||
@@ -187,6 +188,7 @@ class TimetableBloc
|
|||||||
bool renew = false,
|
bool renew = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
await CustomEventsMigration.runOnce();
|
||||||
final events = await repo.data.getCustomEvents(
|
final events = await repo.data.getCustomEvents(
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import '../../../../../api/demo/data/demo_timetable.dart';
|
import '../../../../../api/demo/data/demo_timetable.dart';
|
||||||
import '../../../../../api/demo/demo_mode.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.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.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_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.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.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/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/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 '../../../../../api/request_cache.dart';
|
||||||
import '../../../../../model/account_data.dart';
|
|
||||||
|
|
||||||
/// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is
|
/// 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
|
/// 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 {
|
class TimetableDataProvider {
|
||||||
Future<TimetableGetWeekResponse> getWeek(
|
Future<TimetableGetWeekResponse> getWeek(
|
||||||
DateTime startDate,
|
DateTime startDate,
|
||||||
@@ -100,8 +95,7 @@ class TimetableDataProvider {
|
|||||||
}) {
|
}) {
|
||||||
if (DemoMode.active) return Future.value(DemoTimetable.customEvents());
|
if (DemoMode.active) return Future.value(DemoTimetable.customEvents());
|
||||||
return resolveFromCache<GetCustomTimetableEventResponse>(
|
return resolveFromCache<GetCustomTimetableEventResponse>(
|
||||||
(onUpdate, onError) => GetCustomTimetableEventCache(
|
(onUpdate, onError) => TimetableCustomEventsCache(
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: onUpdate,
|
onUpdate: onUpdate,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
@@ -113,20 +107,16 @@ class TimetableDataProvider {
|
|||||||
|
|
||||||
Future<void> addCustomEvent(CustomTimetableEvent event) {
|
Future<void> addCustomEvent(CustomTimetableEvent event) {
|
||||||
if (DemoMode.active) return Future.value();
|
if (DemoMode.active) return Future.value();
|
||||||
return AddCustomTimetableEvent(
|
return TimetableCustomEventsAdd().run(event);
|
||||||
AddCustomTimetableEventParams(AccountData().getUserSecret(), event),
|
|
||||||
).run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) {
|
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) {
|
||||||
if (DemoMode.active) return Future.value();
|
if (DemoMode.active) return Future.value();
|
||||||
return UpdateCustomTimetableEvent(
|
return TimetableCustomEventsUpdate().run(id, event);
|
||||||
UpdateCustomTimetableEventParams(id, event),
|
|
||||||
).run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeCustomEvent(String id) {
|
Future<void> removeCustomEvent(String id) {
|
||||||
if (DemoMode.active) return Future.value();
|
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();
|
Haptics.selection();
|
||||||
final enabled = e ?? false;
|
final enabled = e ?? false;
|
||||||
settings.val(write: true).notificationSettings.enabled = enabled;
|
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) {
|
if (enabled) {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
unawaited(() async {
|
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,
|
text: widget.existingEvent?.description ?? widget.initialDescription,
|
||||||
);
|
);
|
||||||
late String _rrule = widget.existingEvent?.rrule ?? '';
|
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(
|
late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere(
|
||||||
(e) => e.name == widget.existingEvent?.color,
|
(e) => e.name == widget.existingEvent?.color,
|
||||||
orElse: () => TimetableColors.defaultColor,
|
orElse: () => TimetableColors.defaultColor,
|
||||||
@@ -150,6 +154,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
endDate: endDate,
|
endDate: endDate,
|
||||||
color: _color.name,
|
color: _color.name,
|
||||||
rrule: _rrule,
|
rrule: _rrule,
|
||||||
|
skipHolidays: _rrule.trim().isEmpty ? false : _skipHolidays,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: 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 '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_subjects/timetable_get_subjects_response.dart';
|
||||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
@@ -16,6 +17,7 @@ class TimetableAppointmentFactory {
|
|||||||
final List<McTimetableEntry> lessons;
|
final List<McTimetableEntry> lessons;
|
||||||
final List<CustomTimetableEvent> customEvents;
|
final List<CustomTimetableEvent> customEvents;
|
||||||
final List<McSubject> subjects;
|
final List<McSubject> subjects;
|
||||||
|
final List<McHoliday> holidays;
|
||||||
final TimetableSettings settings;
|
final TimetableSettings settings;
|
||||||
final DateTime now;
|
final DateTime now;
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ class TimetableAppointmentFactory {
|
|||||||
required this.subjects,
|
required this.subjects,
|
||||||
required this.settings,
|
required this.settings,
|
||||||
required this.now,
|
required this.now,
|
||||||
|
this.holidays = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
List<Appointment> build() {
|
List<Appointment> build() {
|
||||||
@@ -98,6 +101,14 @@ class TimetableAppointmentFactory {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.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(
|
return Appointment(
|
||||||
id: CustomAppointment(event),
|
id: CustomAppointment(event),
|
||||||
startTime: event.startDate,
|
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
|
/// All-day convention: a `CustomTimetableEvent` is treated as all-day when
|
||||||
/// its `startDate` and `endDate` both land on midnight of the same day.
|
/// its `startDate` and `endDate` both land on midnight of the same day.
|
||||||
/// Keeps the backend schema unchanged — the editor stores all-day events as
|
/// 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(),
|
lessons: state.getAllKnownLessons().toList(),
|
||||||
customEvents: widget.customEvents,
|
customEvents: widget.customEvents,
|
||||||
subjects: state.subjects?.result ?? const [],
|
subjects: state.subjects?.result ?? const [],
|
||||||
|
holidays: state.schoolHolidays?.result ?? const [],
|
||||||
settings: timetableSettings,
|
settings: timetableSettings,
|
||||||
now: DateTime.now(),
|
now: DateTime.now(),
|
||||||
).build();
|
).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