From 2be5051d12b3f136d2c424b4e8dafc4108bcefc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Mon, 6 Jul 2026 21:15:39 +0200 Subject: [PATCH] migrated the breaker system to MarianumConnect and refactored the API response to a rule-based model with support for version-specific blocks using `maxBuild`. --- .../queries/get_breakers/get_breakers.dart | 26 +++++++ .../get_breakers/get_breakers_cache.dart | 2 +- .../get_breakers/get_breakers_response.dart | 70 ++++++++++++++++++ .../get_breakers/get_breakers_response.g.dart | 39 +++++----- .../breaker/get_breakers/get_breakers.dart | 17 ----- .../get_breakers/get_breakers_response.dart | 42 ----------- lib/app.dart | 2 +- lib/main.dart | 2 +- lib/state/app/modules/app_modules.dart | 12 +-- .../modules/breaker/bloc/breaker_bloc.dart | 14 ++-- .../modules/breaker/bloc/breaker_state.dart | 2 +- .../data_provider/breaker_data_provider.dart | 30 ++++---- lib/widget/breaker/breaker.dart | 73 +++++++++++++++++-- 13 files changed, 213 insertions(+), 118 deletions(-) create mode 100644 lib/api/marianumconnect/queries/get_breakers/get_breakers.dart rename lib/api/{mhsl/breaker => marianumconnect/queries}/get_breakers/get_breakers_cache.dart (84%) create mode 100644 lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart rename lib/api/{mhsl/breaker => marianumconnect/queries}/get_breakers/get_breakers_response.g.dart (55%) delete mode 100644 lib/api/mhsl/breaker/get_breakers/get_breakers.dart delete mode 100644 lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart diff --git a/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart new file mode 100644 index 0000000..1076ac4 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; +import 'get_breakers_response.dart'; + +/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`. +/// The endpoint is public: the bearer token is attached if present but not +/// required, so this also works before login (e.g. to block the whole app). +class GetBreakers { + final Dio _dio; + + GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('breaker'), + ); + return GetBreakersResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers_cache.dart similarity index 84% rename from lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart rename to lib/api/marianumconnect/queries/get_breakers/get_breakers_cache.dart index 1937df5..2a392a1 100644 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers_cache.dart @@ -3,7 +3,7 @@ import 'get_breakers.dart'; import 'get_breakers_response.dart'; class GetBreakersCache extends SimpleCache { - GetBreakersCache({super.onUpdate, super.renew}) + GetBreakersCache({super.onUpdate, super.renew, super.onError}) : super( cacheTime: RequestCache.cacheMinute, loader: () => GetBreakers().run(), diff --git a/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart new file mode 100644 index 0000000..f58b5d7 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart @@ -0,0 +1,70 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'get_breakers_response.g.dart'; + +/// App maintenance breaker rules delivered by +/// `GET /api/mobile/v1/breaker`. Each rule blocks the listed [areas] with a +/// [message]; [BreakerRule.maxBuild] optionally scopes it to old app builds. +/// +/// [rules] defaults to empty so a payload in the pre-migration shape +/// (`global`/`regional`) — which may still sit in the hydrated/cache store +/// after an update — parses to "nothing blocked" instead of throwing. +@JsonSerializable(explicitToJson: true) +class GetBreakersResponse extends ApiResponse { + @JsonKey(defaultValue: []) + List rules; + + GetBreakersResponse(this.rules); + + factory GetBreakersResponse.fromJson(Map json) => + _$GetBreakersResponseFromJson(json); + Map toJson() => _$GetBreakersResponseToJson(this); +} + +@JsonSerializable() +class BreakerRule { + List areas; + String message; + + /// When set, the rule only applies to app builds whose build number is + /// ≤ [maxBuild] (force-update of old versions). `null` = every build. + int? maxBuild; + + BreakerRule(this.areas, this.message, this.maxBuild); + + factory BreakerRule.fromJson(Map json) => + _$BreakerRuleFromJson(json); + Map toJson() => _$BreakerRuleToJson(this); +} + +/// Blockable areas of the app. Each app module maps itself to one of these via +/// its `breakerArea` (see `app_modules.dart`). Must stay in sync with the +/// backend enum `BreakerArea` in the MarianumConnect `marMobileApi` service — +/// the backend only offers these values in the admin UI, so when a new blockable +/// module is added here, add the matching value on the backend too. +enum BreakerArea { + @JsonValue('GLOBAL') + global, + @JsonValue('TIMETABLE') + timetable, + @JsonValue('TALK') + talk, + @JsonValue('FILES') + files, + @JsonValue('NEWS') + news, + @JsonValue('ROOMPLAN') + roomPlan, + @JsonValue('GRADES') + grades, + @JsonValue('HOLIDAYS') + holidays, + @JsonValue('DATES') + dates, + @JsonValue('FEEDBACK') + feedback, + @JsonValue('MORE') + more, +} diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart similarity index 55% rename from lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart rename to lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart index 30d16e6..d446ada 100644 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart @@ -8,15 +8,10 @@ part of 'get_breakers_response.dart'; GetBreakersResponse _$GetBreakersResponseFromJson(Map json) => GetBreakersResponse( - GetBreakersReponseObject.fromJson( - json['global'] as Map, - ), - (json['regional'] as Map).map( - (k, e) => MapEntry( - k, - GetBreakersReponseObject.fromJson(e as Map), - ), - ), + (json['rules'] as List?) + ?.map((e) => BreakerRule.fromJson(e as Map)) + .toList() ?? + [], ) ..headers = (json['headers'] as Map?)?.map( (k, e) => MapEntry(k, e as String), @@ -26,30 +21,34 @@ Map _$GetBreakersResponseToJson( GetBreakersResponse instance, ) => { 'headers': ?instance.headers, - 'global': instance.global.toJson(), - 'regional': instance.regional.map((k, e) => MapEntry(k, e.toJson())), + 'rules': instance.rules.map((e) => e.toJson()).toList(), }; -GetBreakersReponseObject _$GetBreakersReponseObjectFromJson( - Map json, -) => GetBreakersReponseObject( +BreakerRule _$BreakerRuleFromJson(Map json) => BreakerRule( (json['areas'] as List) .map((e) => $enumDecode(_$BreakerAreaEnumMap, e)) .toList(), json['message'] as String, + (json['maxBuild'] as num?)?.toInt(), ); -Map _$GetBreakersReponseObjectToJson( - GetBreakersReponseObject instance, -) => { - 'areas': instance.areas.map((e) => _$BreakerAreaEnumMap[e]!).toList(), - 'message': instance.message, -}; +Map _$BreakerRuleToJson(BreakerRule instance) => + { + 'areas': instance.areas.map((e) => _$BreakerAreaEnumMap[e]!).toList(), + 'message': instance.message, + 'maxBuild': instance.maxBuild, + }; const _$BreakerAreaEnumMap = { BreakerArea.global: 'GLOBAL', BreakerArea.timetable: 'TIMETABLE', BreakerArea.talk: 'TALK', BreakerArea.files: 'FILES', + BreakerArea.news: 'NEWS', + BreakerArea.roomPlan: 'ROOMPLAN', + BreakerArea.grades: 'GRADES', + BreakerArea.holidays: 'HOLIDAYS', + BreakerArea.dates: 'DATES', + BreakerArea.feedback: 'FEEDBACK', BreakerArea.more: 'MORE', }; diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart deleted file mode 100644 index 35d70b0..0000000 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../mhsl_api.dart'; -import 'get_breakers_response.dart'; - -class GetBreakers extends MhslApi { - GetBreakers() : super('breaker/'); - - @override - GetBreakersResponse assemble(String raw) => - GetBreakersResponse.fromJson(jsonDecode(raw) as Map); - - @override - Future? request(Uri uri) => http.get(uri); -} diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart deleted file mode 100644 index c0cb39f..0000000 --- a/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../api_response.dart'; - -part 'get_breakers_response.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetBreakersResponse extends ApiResponse { - GetBreakersReponseObject global; - Map regional; - - GetBreakersResponse(this.global, this.regional); - - factory GetBreakersResponse.fromJson(Map json) => - _$GetBreakersResponseFromJson(json); - Map toJson() => _$GetBreakersResponseToJson(this); -} - -@JsonSerializable() -class GetBreakersReponseObject { - List areas; - String message; - - GetBreakersReponseObject(this.areas, this.message); - - factory GetBreakersReponseObject.fromJson(Map json) => - _$GetBreakersReponseObjectFromJson(json); - Map toJson() => _$GetBreakersReponseObjectToJson(this); -} - -enum BreakerArea { - @JsonValue('GLOBAL') - global, - @JsonValue('TIMETABLE') - timetable, - @JsonValue('TALK') - talk, - @JsonValue('FILES') - files, - @JsonValue('MORE') - more, -} diff --git a/lib/app.dart b/lib/app.dart index 9b0e564..fabb0f8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/mhsl/server/user_index/update/update_userindex.dart'; import 'main.dart'; import 'model/data_cleaner.dart'; diff --git a/lib/main.dart b/lib/main.dart index 4ff73a1..8ae7455 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,7 +20,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; -import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 83b95eb..e375665 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../routing/app_routes.dart'; import '../../../view/pages/files/files.dart'; import '../../../view/pages/grade_averages/grade_averages_view.dart'; @@ -95,35 +95,35 @@ class AppModule { Modules.marianumMessage, name: 'Marianum Message', icon: () => Icon(Icons.newspaper), - breakerArea: BreakerArea.more, + breakerArea: BreakerArea.news, create: MarianumMessageListView.new, ), Modules.roomPlan: AppModule( Modules.roomPlan, name: 'Raumplan', icon: () => Icon(Icons.location_pin), - breakerArea: BreakerArea.more, + breakerArea: BreakerArea.roomPlan, create: Roomplan.new, ), Modules.gradeAveragesCalculator: AppModule( Modules.gradeAveragesCalculator, name: 'Notendurchschnittsrechner', icon: () => Icon(Icons.calculate), - breakerArea: BreakerArea.more, + breakerArea: BreakerArea.grades, create: GradeAveragesView.new, ), Modules.holidays: AppModule( Modules.holidays, name: 'Schulferien', icon: () => Icon(Icons.beach_access_outlined), - breakerArea: BreakerArea.more, + breakerArea: BreakerArea.holidays, create: HolidaysView.new, ), Modules.marianumDates: AppModule( Modules.marianumDates, name: 'Marianum Termine', icon: () => Icon(Icons.event_note), - breakerArea: BreakerArea.more, + breakerArea: BreakerArea.dates, create: MarianumDatesView.new, ), }; diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart index 24af779..67d75ac 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/breaker_repository.dart'; @@ -40,13 +40,11 @@ class BreakerBloc final response = innerState?.response; if (response == null || _packageInfo == null) return null; - if (response.global.areas.contains(type)) return response.global.message; - - final selfBuild = int.parse(_packageInfo!.buildNumber); - for (final entry in response.regional.entries) { - final affectedBuild = int.parse(entry.key.split('b')[1]); - if (affectedBuild >= selfBuild && entry.value.areas.contains(type)) { - return entry.value.message; + final selfBuild = int.tryParse(_packageInfo!.buildNumber) ?? 0; + for (final rule in response.rules) { + if (!rule.areas.contains(type)) continue; + if (rule.maxBuild == null || selfBuild <= rule.maxBuild!) { + return rule.message; } } return null; diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart index 367688f..b79c01c 100644 --- a/lib/state/app/modules/breaker/bloc/breaker_state.dart +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -1,6 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; part 'breaker_state.freezed.dart'; part 'breaker_state.g.dart'; diff --git a/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart index 1f8ed6b..be5a131 100644 --- a/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart +++ b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart @@ -1,16 +1,20 @@ -import 'dart:async'; - -import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_cache.dart'; -import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../../../../api/marianumconnect/queries/get_breakers/get_breakers_cache.dart'; +import '../../../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; +import '../../../../../api/request_cache.dart'; class BreakerDataProvider { - Future getBreakers() { - final completer = Completer(); - GetBreakersCache( - onUpdate: (data) { - if (!completer.isCompleted) completer.complete(data); - }, - ); - return completer.future; - } + /// Resolves via [resolveFromCache] (not first-emit-wins): the cache emits its + /// stored value first for an instant display, then the network value — and we + /// return the latter once [RequestCache.ready] settles. `renew: true` forces a + /// fresh network check on every call so a just-changed breaker takes effect on + /// the next `refresh()` (app resume) instead of only after a restart. + Future getBreakers({ + bool renew = true, + void Function(Object)? onError, + }) => resolveFromCache( + (onUpdate, onError) => + GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getBreakers', + ); } diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart index b969096..bfec78c 100644 --- a/lib/widget/breaker/breaker.dart +++ b/lib/widget/breaker/breaker.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; -import '../../widget/placeholder_view.dart'; class Breaker extends StatelessWidget { final BreakerArea breaker; @@ -16,13 +15,71 @@ class Breaker extends StatelessWidget { final bloc = context.watch(); final blocked = bloc.isBlocked(breaker); if (blocked != null) { - return PlaceholderView( - icon: Icons.app_blocking_outlined, - text: - 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' - '${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}', - ); + return _BreakerBlockedView(message: blocked); } return child; } } + +class _BreakerBlockedView extends StatelessWidget { + final String message; + + const _BreakerBlockedView({required this.message}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final reason = message.trim(); + + return Material( + color: scheme.surface, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.construction_outlined, + size: 60, + color: scheme.onSurfaceVariant, + ), + const SizedBox(height: 24), + Text( + 'Momentan nicht verfügbar', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + if (reason.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + reason, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + ); + } +}