27 Commits

Author SHA1 Message Date
MineTec 15791423ea added fileFetchedAt to TickerPageResponse and updated the Ticker UI to use it as the data currency timestamp for proxied files 2026-07-13 00:32:27 +02:00
MineTec 2f5a6b4ce0 split file_viewer into focused sub-widgets 2026-07-13 00:01:07 +02:00
MineTec 53bc6d5360 share weekKey extension and log splash precache failures 2026-07-12 23:56:47 +02:00
MineTec f50359b4eb use AppProgressIndicator for plain loading spinners 2026-07-12 23:45:55 +02:00
MineTec 9994a1f3fa extract SettingsDropdownTile for settings dropdowns 2026-07-12 23:42:56 +02:00
MineTec 4aa31a2e44 extract SettingsCheckboxTile for settings toggles 2026-07-12 23:41:12 +02:00
MineTec dfce3e7b5c add PromptDialog helper for text input dialogs 2026-07-12 23:36:46 +02:00
MineTec 564a334cdc route avatar crop and profile view through AppRoutes 2026-07-12 23:32:48 +02:00
MineTec db329c7299 adopt MarianumConnectQuery base in remaining queries 2026-07-12 23:28:51 +02:00
MineTec 0a2ff5c3fb add MarianumConnectQuery base and adopt it in list queries 2026-07-12 23:24:21 +02:00
MineTec 9b5198c6db add ticker page bloc and avatar disk cache 2026-07-12 23:18:53 +02:00
MineTec 94794ff092 remove unused dead code files 2026-07-12 23:17:47 +02:00
MineTec fe2b3c43b2 implemented custom subject colors for the timetable and unified the color palette system 2026-07-12 21:30:08 +02:00
MineTec a7111844b1 bugfixes, better background operation robustness and platform-specific error handling 2026-07-12 19:17:49 +02:00
MineTec babc347b18 implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic 2026-07-12 19:05:27 +02:00
MineTec 44e45c9b78 implemented full interactive poll support in Talk, including creation, voting, and closing functionality 2026-07-12 17:54:30 +02:00
MineTec 3f44e9302f implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog 2026-07-12 16:42:30 +02:00
MineTec 59501d3b45 implemented enlarged emoji rendering for chat messages containing up to three emojis 2026-07-12 16:26:22 +02:00
MineTec 91a6216f66 migrated custom timetable events to Marianum-Connect and refactored push notification handling 2026-07-12 14:31:48 +02:00
MineTec c444ed54a5 migrated Marianum Message module to the Marianum-Connect API and implemented push notification deep linking 2026-07-11 17:37:50 +02:00
MineTec ff23199345 downgrade AGP and Gradle versions, update Ticker icon, and upgrade dependencies 2026-07-11 15:32:09 +02:00
MineTec 9545d2a946 upgraded Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9.4.1 2026-07-10 19:16:07 +02:00
MineTec 37608e59b3 implemented Ticker page selection persistence and enhanced ProseMirror table display modes 2026-07-10 19:15:52 +02:00
MineTec 0d01f6b631 fixed push-registration not activated on app-login 2026-07-10 15:35:56 +02:00
MineTec b0d2e7a34b fixed inconsistent placement of appbar title on iOS 2026-07-09 12:04:20 +02:00
MineTec cedeb06569 implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files 2026-07-09 00:51:08 +02:00
MineTec 1114291313 Add Fastlane Play Store integration with automated screenshots
Fastlane supply config (android/fastlane) with de-DE metadata structure,
integration_test-driven screenshot automation (screenshot_test.dart,
test_driver, tool/screenshots.sh) using stable keys on the login form,
refreshed materials/screenshots (phone + 7-inch) and app banner/symbol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:43:10 +02:00
263 changed files with 11154 additions and 2105 deletions
+12
View File
@@ -350,3 +350,15 @@ hs_err_pid*
*.idea*
**/.DS_store
# Fastlane (Play-Store-Upload)
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/play-service-account.json
android/fastlane/README.md.bak
# lokale Screenshots außerhalb der Fastlane-Metadaten
/screenshots/
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
+6
View File
@@ -0,0 +1,6 @@
package_name("eu.mhsl.marianum.mobile.client")
# Service-Account-JSON für die Google Play Developer API. Pfad über die
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
# .gitignore nicht eingecheckt).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
+29
View File
@@ -0,0 +1,29 @@
default_platform(:android)
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
# metadata/android/<locale>/.
platform :android do
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
metadata_path: "./fastlane/metadata/android",
)
end
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
lane :upload_metadata do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
metadata_path: "./fastlane/metadata/android",
)
end
end
+2
View File
@@ -0,0 +1,2 @@
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
# Plugin-Handling initialisiert.
+35
View File
@@ -0,0 +1,35 @@
# Fastlane Play-Store-Screenshots & Upload
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
gebaut werden der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
aus Fixtures.
## 1. Screenshots aufnehmen
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
**Client**-Wurzelverzeichnis:
```bash
tool/screenshots.sh
```
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
## 2. Hochladen
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
```bash
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
cd android
fastlane upload_screenshots # nur Bilder
# oder
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
```
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+215
View File
@@ -0,0 +1,215 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:marianum_mobile/app.dart';
import 'package:marianum_mobile/main.dart' as app;
import 'package:marianum_mobile/routing/app_routes.dart';
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Phone / 7″ / 10″).
///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch.
Future<void> main() async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
// Der integration_test-Harness installiert eigene Error-Handler. app()
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
// Daher vorher sichern und nach main() zurücksetzen.
final harnessFlutterOnError = FlutterError.onError;
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
_log('main() starten');
await app.main();
FlutterError.onError = harnessFlutterOnError;
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
await tester.pump(const Duration(seconds: 1));
_log('Login');
await _login(tester);
_log('warte auf App-Shell');
await _pumpUntil(
tester,
find.byType(App),
timeout: const Duration(seconds: 20),
);
_log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone(
tester,
find.byKey(const ValueKey('post-login-splash')),
timeout: const Duration(seconds: 12),
);
await tester.pump(const Duration(seconds: 1));
// Screenshots auf Android brauchen eine einmalige Umwandlung der
// Flutter-Surface in eine lesbare Image-Texture. Erst hier nach Login und
// Splash , damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
if (Platform.isAndroid) {
_log('convertFlutterSurfaceToImage');
await binding.convertFlutterSurfaceToImage();
}
// 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light);
await _capture(tester, binding, '1_stundenplan_hell');
_setTheme(tester, ThemeMode.dark);
await _capture(tester, binding, '2_stundenplan_dunkel');
// Restlicher Satz einheitlich im hellen Theme.
_setTheme(tester, ThemeMode.light);
// 3: Talk-Liste.
await _goTo(tester, Modules.talk);
await _capture(tester, binding, '3_talk_liste');
// 4: Talk-Chat (ersten Chat der Liste öffnen).
await tester.tap(find.byType(ChatTile).first);
await _capture(tester, binding, '4_talk_chat');
_popPushedPage(tester);
await tester.pump(const Duration(milliseconds: 500));
// 5: Dateien.
await _goTo(tester, Modules.files);
await _capture(tester, binding, '5_dateien');
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
_goToMore(tester);
await _capture(tester, binding, '6_mehr');
// 7: Einstellungen (Vollbild-Push).
AppRoutes.openSettings(tester.element(find.byType(App)));
await _capture(tester, binding, '7_einstellungen');
_popPushedPage(tester);
_log('fertig');
});
}
void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil(
tester,
find.byKey(const Key('login-username-field')),
);
if (!loginVisible) {
_log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login');
return;
}
await tester.enterText(
find.byKey(const Key('login-username-field')),
'demo@screenshots',
);
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
await tester.pump();
await tester.tap(find.byKey(const Key('login-submit-button')));
await tester.pump(const Duration(milliseconds: 500));
}
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
void _setTheme(WidgetTester tester, ThemeMode mode) {
final context = tester.element(find.byType(App));
context.read<SettingsCubit>().val(write: true).appTheme = mode;
}
/// Navigiert zu [module] über den Bottom-Tab, wenn er in der Leiste liegt,
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async {
final appFinder = find.byType(App);
if (appFinder.evaluate().isEmpty) {
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
return;
}
final context = tester.element(appFinder);
if (!AppRoutes.goToTab(context, module)) {
final resolved = AppModule.modules(context)[module];
if (resolved != null) AppRoutes.openModule(context, resolved);
}
}
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
/// also die Anzahl der Bottom-Bar-Module.
void _goToMore(WidgetTester tester) {
final context = tester.element(find.byType(App));
final moreIndex = AppModule.getBottomBarModules(context).length;
app.Main.bottomNavigator.jumpToTab(moreIndex);
}
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
void _popPushedPage(WidgetTester tester) {
final navigator = AppRoutes.rootNavigatorKey.currentState;
if (navigator != null && navigator.canPop()) navigator.pop();
}
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
Future<void> _capture(
WidgetTester tester,
IntegrationTestWidgetsFlutterBinding binding,
String name,
) async {
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
await _settle(tester, const Duration(seconds: 6));
_log('-> $name: takeScreenshot');
await binding.takeScreenshot(name);
}
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
/// Dauer-Animationen hängen bleibt).
Future<void> _settle(WidgetTester tester, Duration duration) async {
final steps = duration.inMilliseconds ~/ 500;
for (var i = 0; i < steps; i++) {
await tester.pump(const Duration(milliseconds: 500));
}
}
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
/// Gibt zurück, ob [finder] gefunden wurde.
Future<bool> _pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return true;
}
return false;
}
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
Future<bool> _pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isEmpty) return true;
}
return false;
}
+5 -3
View File
@@ -1,3 +1,5 @@
import 'package:intl/intl.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
/// Demo fixtures for the info messages — a single friendly welcome entry so the
@@ -6,16 +8,16 @@ class DemoMarianumMessage {
const DemoMarianumMessage._();
static MarianumMessageList list() {
final today = DateTime.now();
final date =
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
final date = DateFormat.yMMMM('de').format(DateTime.now());
return MarianumMessageList(
base: 'https://marianum-fulda.de',
messages: [
MarianumMessage(
id: 'demo',
name: 'Willkommen in der Marianum-App',
date: date,
description: 'Eine kurze Begrüßung zum Ausprobieren.',
url: 'https://marianum-fulda.de',
),
],
+112
View File
@@ -0,0 +1,112 @@
import '../../marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
/// Demo fixtures for the ticker module — a small, self-contained example doc so
/// the "Aktuelles" surface and the page tree are populated without any network.
class DemoTicker {
const DemoTicker._();
static Map<String, dynamic> _welcomeDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 1},
'content': [
{'type': 'text', 'text': 'Willkommen beim Ticker'},
],
},
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Hier erscheinen aktuelle Informationen '},
{
'type': 'text',
'text': 'der Schule',
'marks': [
{'type': 'bold'},
],
},
{'type': 'text', 'text': '.'},
],
},
{
'type': 'callout',
'attrs': {'variant': 'info'},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Dies ist eine Beispiel-Meldung.'},
],
},
],
},
],
};
static Map<String, dynamic> _pageDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 2},
'content': [
{'type': 'text', 'text': 'Über diese Seite'},
],
},
{
'type': 'paragraph',
'content': [
{
'type': 'text',
'text': 'Eine Beispielseite mit nativ gerendertem Inhalt.',
},
],
},
],
};
static TickerResponse ticker() => TickerResponse(
schemaVersion: 1,
available: true,
hash: 'demo-ticker',
publishedAt: DateTime.now().toIso8601String(),
webUrl: '/ticker',
content: _welcomeDoc(),
);
static TickerNavResponse nav() => TickerNavResponse(
schemaVersion: 1,
navHash: 'demo-nav',
sections: [
TickerNavSection(
title: 'Informationen',
pages: [
TickerNavPage(
title: 'Über die App',
slug: 'ueber-die-app',
kind: TickerPageKind.content,
hash: 'demo-page',
),
TickerNavPage(
title: 'Schulwebseite',
slug: 'schulwebseite',
kind: TickerPageKind.redirect,
externalUrl: 'https://marianum-fulda.de',
),
],
),
],
);
static TickerPageResponse page(String slug) => TickerPageResponse(
schemaVersion: 1,
slug: slug,
title: 'Über die App',
kind: TickerPageKind.content,
content: _pageDoc(),
webUrl: '/ticker/p/$slug',
);
}
+50
View File
@@ -0,0 +1,50 @@
import '../../marianumconnect/queries/user_search/user_search_response.dart';
/// Demo fixtures for the Talk user search — a small mixed set of teachers and
/// students, filtered client-side so the demo search feels responsive.
class DemoUsers {
const DemoUsers._();
static final List<McUserSearchResult> _all = [
McUserSearchResult(
username: 'm.muster',
firstName: 'Maria',
lastName: 'Mustermann',
userType: 'TEACHER',
),
McUserSearchResult(
username: 'j.beispiel',
firstName: 'Jonas',
lastName: 'Beispiel',
userType: 'TEACHER',
),
McUserSearchResult(
username: 'l.schueler',
firstName: 'Lena',
lastName: 'Schüler',
userType: 'STUDENT',
className: '9c',
),
McUserSearchResult(
username: 'p.probe',
firstName: 'Paul',
lastName: 'Probe',
userType: 'STUDENT',
className: 'Q2',
),
];
static List<McUserSearchResult> search(String query) {
final q = query.trim().toLowerCase();
if (q.length < 2) return const [];
return _all
.where(
(u) =>
u.firstName.toLowerCase().contains(q) ||
u.lastName.toLowerCase().contains(q) ||
u.username.toLowerCase().contains(q) ||
'${u.firstName} ${u.lastName}'.toLowerCase().contains(q),
)
.toList();
}
}
+5
View File
@@ -1,6 +1,7 @@
import 'data/demo_breaker.dart';
import 'data/demo_holidays.dart';
import 'data/demo_timetable.dart';
import 'data/demo_users.dart';
/// Single source of truth for the MarianumConnect demo responses. The demo
/// interceptor asks this for the body of any MC request. Read endpoints reuse
@@ -32,6 +33,10 @@ class DemoMarianumConnect {
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search':
return DemoUsers.search(query['q']?.toString() ?? '')
.map((e) => e.toJson())
.toList();
case 'breaker':
return DemoBreaker.none().toJson();
case 'timetable/elements/teachers':
@@ -0,0 +1,19 @@
import 'app_exception.dart';
/// Raised when a CONTENT ticker page has no native ProseMirror payload yet
/// (404 `CONTENT_UNAVAILABLE`). Carries the site-relative [webUrl] so the UI
/// can offer to open the page in the browser instead. Not retryable — the
/// content only appears after an admin re-saves the page.
class TickerContentUnavailableException extends AppException {
final String? webUrl;
const TickerContentUnavailableException({
this.webUrl,
super.technicalDetails,
}) : super(
userMessage:
'Diese Seite ist in der App noch nicht verfügbar. '
'Du kannst sie im Browser öffnen.',
allowRetry: false,
);
}
@@ -0,0 +1,28 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
/// Schließt eine Umfrage endgültig — nur Ersteller oder Moderatoren.
class ClosePoll extends TalkApi<GetPollStateResponse> {
ClosePoll({required String token, required int pollId})
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../talk_api.dart';
import 'create_poll_params.dart';
/// Erstellt eine Umfrage; der Server postet die Poll-Nachricht selbst in den
/// Chat, danach genügt ein Chat-Refresh. Nur in Gruppen-Chats erlaubt.
class CreatePoll extends TalkApi {
CreatePoll({required String token, required CreatePollParams params})
: super(
'v1/poll/$token',
params,
headers: {'Content-Type': 'application/json'},
);
@override
Null assemble(String raw) => null;
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! CreatePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_params.dart';
part 'create_poll_params.g.dart';
@JsonSerializable()
class CreatePollParams extends ApiParams {
String question;
List<String> options;
/// 0 = Ergebnisse öffentlich, 1 = bis zum Schließen verborgen.
int resultMode;
/// Stimmen pro Teilnehmer; 0 = unbegrenzt.
int maxVotes;
CreatePollParams({
required this.question,
required this.options,
required this.resultMode,
required this.maxVotes,
});
factory CreatePollParams.fromJson(Map<String, dynamic> json) =>
_$CreatePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$CreatePollParamsToJson(this);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
CreatePollParams(
question: json['question'] as String,
options: (json['options'] as List<dynamic>)
.map((e) => e as String)
.toList(),
resultMode: (json['resultMode'] as num).toInt(),
maxVotes: (json['maxVotes'] as num).toInt(),
);
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{
'question': instance.question,
'options': instance.options,
'resultMode': instance.resultMode,
'maxVotes': instance.maxVotes,
};
@@ -1,16 +1,22 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../talk_api.dart';
import 'create_room_params.dart';
import 'create_room_response.dart';
class CreateRoom extends TalkApi {
class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoomParams params;
CreateRoom(this.params) : super('v4/room', params);
@override
Null assemble(String raw) => null;
CreateRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
@override
Future<Response>? request(
@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'create_room_response.g.dart';
@JsonSerializable(explicitToJson: true)
class CreateRoomResponse extends ApiResponse {
final CreateRoomResponseData data;
CreateRoomResponse(this.data);
factory CreateRoomResponse.fromJson(Map<String, dynamic> json) =>
_$CreateRoomResponseFromJson(json);
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this);
}
@JsonSerializable()
class CreateRoomResponseData {
final String token;
CreateRoomResponseData(this.token);
factory CreateRoomResponseData.fromJson(Map<String, dynamic> json) =>
_$CreateRoomResponseDataFromJson(json);
Map<String, dynamic> toJson() => _$CreateRoomResponseDataToJson(this);
}
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_room_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
CreateRoomResponse(
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{
'headers': ?instance.headers,
'data': instance.data.toJson(),
};
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json,
) => CreateRoomResponseData(json['token'] as String);
Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance,
) => <String, dynamic>{'token': instance.token};
@@ -4,6 +4,18 @@ import '../../../api_response.dart';
part 'get_poll_state_response.g.dart';
/// Poll-`status`-Werte der Talk-API.
const int pollStatusOpen = 0;
const int pollStatusClosed = 1;
/// Poll-`resultMode`-Werte der Talk-API.
const int pollResultModePublic = 0;
const int pollResultModeHidden = 1;
/// `participantType`-Werte (aus dem Room), die eine Umfrage schließen dürfen:
/// Owner (1), Moderator (2) und Gast-Moderator (6).
const Set<int> pollModeratorParticipantTypes = {1, 2, 6};
@JsonSerializable(explicitToJson: true)
class GetPollStateResponse extends ApiResponse {
GetPollStateResponseObject data;
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetPollStateResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
bool get isClosed => status == pollStatusClosed;
bool get resultsHidden => resultMode == pollResultModeHidden;
/// Ergebnisse sichtbar: öffentliche Umfragen jederzeit, verborgene erst nach
/// dem Schließen. Der Typ von `votes` taugt nicht als Signal (siehe unten).
bool get resultsVisible => resultMode == pollResultModePublic || isClosed;
/// Normalisiert das dynamische `votes`-Feld zu einer Map: der Server liefert
/// bei verborgenen Ergebnissen (und ohne Stimmen) eine leere Liste statt Map.
Map<String, num> get voteCounts {
final raw = votes;
if (raw is! Map) return const {};
final result = <String, num>{};
raw.forEach((key, value) {
if (key is String && value is num) result[key] = value;
});
return result;
}
/// Darf der Nutzer die (offene) Umfrage schließen: als Ersteller oder Moderator.
bool canClose({required String selfId, required int participantType}) {
if (isClosed) return false;
final isCreator = actorType == 'users' && actorId == selfId;
final isModerator = pollModeratorParticipantTypes.contains(participantType);
return isCreator || isModerator;
}
}
@@ -0,0 +1,41 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
import 'vote_poll_params.dart';
class VotePoll extends TalkApi<GetPollStateResponse> {
// Body als echtes JSON (nicht form-encoded wie die anderen Endpunkte): nur
// so kommt das int-Array an; sonst liest der Server optionIds als [] und
// löscht die eigene Stimme (Ursache des Readonly-Fallbacks, Issue #42).
VotePoll({
required String token,
required int pollId,
required VotePollParams params,
}) : super(
'v1/poll/$token/$pollId',
params,
headers: {'Content-Type': 'application/json'},
);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! VotePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_params.dart';
part 'vote_poll_params.g.dart';
@JsonSerializable()
class VotePollParams extends ApiParams {
/// Indizes der gewählten Optionen; leer = eigene Stimme zurückziehen.
List<int> optionIds;
VotePollParams({required this.optionIds});
factory VotePollParams.fromJson(Map<String, dynamic> json) =>
_$VotePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$VotePollParamsToJson(this);
}
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vote_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
VotePollParams(
optionIds: (json['optionIds'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
<String, dynamic>{'optionIds': instance.optionIds};
@@ -1,14 +0,0 @@
import '../../../../api_response.dart';
import '../../webdav_api.dart';
import 'download_file_params.dart';
class DownloadFile extends WebdavApi<DownloadFileParams> {
DownloadFileParams params;
DownloadFile(this.params) : super(params);
@override
Future<ApiResponse> run() async {
throw UnimplementedError();
}
}
@@ -1,22 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../../api_params.dart';
part 'download_file_params.g.dart';
@JsonSerializable()
class DownloadFileParams extends ApiParams {
String webdavSourcePath;
String localTargetPath;
String filename;
DownloadFileParams(
this.webdavSourcePath,
this.localTargetPath,
this.filename,
);
factory DownloadFileParams.fromJson(Map<String, dynamic> json) =>
_$DownloadFileParamsFromJson(json);
Map<String, dynamic> toJson() => _$DownloadFileParamsToJson(this);
}
@@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_file_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloadFileParams _$DownloadFileParamsFromJson(Map<String, dynamic> json) =>
DownloadFileParams(
json['webdavSourcePath'] as String,
json['localTargetPath'] as String,
json['filename'] as String,
);
Map<String, dynamic> _$DownloadFileParamsToJson(DownloadFileParams instance) =>
<String, dynamic>{
'webdavSourcePath': instance.webdavSourcePath,
'localTargetPath': instance.localTargetPath,
'filename': instance.filename,
};
@@ -1,14 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'download_file_response.g.dart';
@JsonSerializable()
class DownloadFileResponse {
String path;
DownloadFileResponse(this.path);
factory DownloadFileResponse.fromJson(Map<String, dynamic> json) =>
_$DownloadFileResponseFromJson(json);
Map<String, dynamic> toJson() => _$DownloadFileResponseToJson(this);
}
@@ -1,15 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_file_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloadFileResponse _$DownloadFileResponseFromJson(
Map<String, dynamic> json,
) => DownloadFileResponse(json['path'] as String);
Map<String, dynamic> _$DownloadFileResponseToJson(
DownloadFileResponse instance,
) => <String, dynamic>{'path': instance.path};
@@ -36,7 +36,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
// Token mitschicken statt ein eigenes 401 einzufangen.
final pending = _pendingReLogin;
if (pending != null) await pending;
final token = await _tokenStorage.readToken();
// Reading the keystore can throw while the device is locked (iOS
// errSecInteractionNotAllowed on background requests). Degrade to an
// unauthenticated request instead of surfacing a platform error.
String? token;
try {
token = await _tokenStorage.readToken();
} catch (_) {
token = null;
}
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
@@ -1,5 +1,13 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
/// `-25308 errSecInteractionNotAllowed` when the device is locked.
const IOSOptions _mcIosOptions = IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
);
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
@@ -11,7 +19,7 @@ class MarianumConnectTokenStorage {
final FlutterSecureStorage _storage;
const MarianumConnectTokenStorage([
this._storage = const FlutterSecureStorage(),
this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions),
]);
Future<String?> readToken() => _storage.read(key: _tokenKey);
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart';
/// Shared base for MarianumConnect API queries. Owns the [dio] client (the
/// shared authenticated singleton by default) and routes calls through [guard]
/// so every query maps a DioException to the app's typed AppExceptions the same
/// way instead of repeating the try/catch. Subclasses with bespoke error or
/// lifecycle handling (own dio, silent failure, custom status mapping) may skip
/// [guard] and still reuse [dio]/[endpoint].
abstract class MarianumConnectQuery {
final Dio dio;
MarianumConnectQuery({Dio? dio}) : dio = dio ?? MarianumConnectApi.dio();
/// Resolves [path] against the active mobile-API base URL.
String endpoint(String path) => MarianumConnectEndpoint.resolve(path);
/// Runs [body], converting any DioException into the matching AppException.
Future<T> guard<T>(Future<T> Function() body) async {
try {
return await body();
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -1,61 +1,55 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'auth_login_response.dart';
/// Performs the Marianum-Connect bearer login. Used both by the foreground
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
/// run through the shared dio instance — that one has the interceptor, which
/// would attempt to re-auth us into a loop if our credentials are wrong.
class AuthLogin {
class AuthLogin extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogin({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
Future<AuthLoginResponse> run({
required String username,
required String password,
required String tokenName,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('auth/login'),
data: {
'username': username,
'password': password,
'tokenName': tokenName,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/login'),
data: {
'username': username,
'password': password,
'tokenName': tokenName,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
});
}
@@ -1,26 +1,23 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
/// — a network error still clears the local token so the user isn't stuck with
/// an unusable session.
class AuthLogout {
class AuthLogout extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogout({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio = dio ?? MarianumConnectApi.dio();
super.dio,
}) : _tokenStorage = tokenStorage;
Future<void> run() async {
try {
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
await dio.post<void>(endpoint('auth/logout'));
} on DioException catch (_) {
// ignore — local clear below still happens
} finally {
@@ -2,8 +2,7 @@ import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token still maps to the given credentials.
/// Server returns 200 only when the credentials belong to the user that the
@@ -12,29 +11,28 @@ import '../../marianumconnect_endpoint.dart';
///
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
/// and obscure a real 401 with a silent re-login.
class AuthVerify {
class AuthVerify extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
/// Throws [AuthException] on 401 (credentials no longer match the token's
/// user, token missing, or token rejected), other [AppException]s on
@@ -49,14 +47,12 @@ class AuthVerify {
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('auth/verify'),
return guard(() async {
await dio.post<void>(
endpoint('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
});
}
}
@@ -1,26 +1,14 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.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;
class GetBreakers extends MarianumConnectQuery {
GetBreakers({super.dio});
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<GetBreakersResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('breaker'),
);
return GetBreakersResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<GetBreakersResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
return GetBreakersResponse.fromJson(response.data!);
});
}
@@ -55,6 +55,8 @@ enum BreakerArea {
files,
@JsonValue('NEWS')
news,
@JsonValue('TICKER')
ticker,
@JsonValue('ROOMPLAN')
roomPlan,
@JsonValue('GRADES')
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
BreakerArea.talk: 'TALK',
BreakerArea.files: 'FILES',
BreakerArea.news: 'NEWS',
BreakerArea.ticker: 'TICKER',
BreakerArea.roomPlan: 'ROOMPLAN',
BreakerArea.grades: 'GRADES',
BreakerArea.holidays: 'HOLIDAYS',
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_capabilities_response.dart';
/// Fetches the current user's mobile capability flags from
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
/// so the bearer token is attached automatically.
class GetCapabilities {
final Dio _dio;
class GetCapabilities extends MarianumConnectQuery {
GetCapabilities({super.dio});
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<CapabilitiesResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<CapabilitiesResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
});
}
@@ -1,25 +1,13 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import '../../models/mc_holiday.dart';
class GetHolidays {
final Dio _dio;
class GetHolidays extends MarianumConnectQuery {
GetHolidays({super.dio});
GetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<List<McHoliday>> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('holidays'),
);
return response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<List<McHoliday>> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
return response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
});
}
@@ -0,0 +1,25 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from
/// `GET /api/mobile/v1/newsletter/{id}/file`.
///
/// Goes through the shared MC dio so the bearer token is attached automatically;
/// the bytes are handed to `SfPdfViewer.memory` so no auth header has to be
/// plumbed into the viewer itself.
class GetNewsletterFile extends MarianumConnectQuery {
final String id;
GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
}
@@ -0,0 +1,13 @@
import '../../marianumconnect_query.dart';
import 'get_ticker_response.dart';
/// Fetches the current "Aktuelles" ticker post from
/// `GET /api/mobile/v1/ticker`. Bearer token is attached by the shared dio.
class GetTicker extends MarianumConnectQuery {
GetTicker({super.dio});
Future<TickerResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
return TickerResponse.fromJson(response.data!);
});
}
@@ -0,0 +1,44 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_response.g.dart';
/// The "Aktuelles" ticker post from `GET /api/mobile/v1/ticker`.
///
/// [available] is false when the current post has no ProseMirror JSON yet (a
/// legacy post not re-saved since the dual-write rollout); the backend still
/// returns 200 with `content: null` so the home surface degrades to a
/// "open in browser" hint rather than an error. Unknown/missing flags default
/// to a safe empty state.
@JsonSerializable()
class TickerResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
@JsonKey(defaultValue: false)
final bool available;
final String? hash;
final String? publishedAt;
/// Site-relative link to the web ticker (e.g. `/ticker`); the app prefixes
/// the active Marianum-Connect base URL.
@JsonKey(defaultValue: '/ticker')
final String webUrl;
/// Nested ProseMirror document (`{ type: doc, content: [...] }`) or null when
/// [available] is false. Parsed to a `PmNode` at render time.
final Map<String, dynamic>? content;
TickerResponse({
required this.schemaVersion,
required this.available,
this.hash,
this.publishedAt,
required this.webUrl,
this.content,
});
factory TickerResponse.fromJson(Map<String, dynamic> json) =>
_$TickerResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerResponseToJson(this);
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
TickerResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
available: json['available'] as bool? ?? false,
hash: json['hash'] as String?,
publishedAt: json['publishedAt'] as String?,
webUrl: json['webUrl'] as String? ?? '/ticker',
content: json['content'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'available': instance.available,
'hash': instance.hash,
'publishedAt': instance.publishedAt,
'webUrl': instance.webUrl,
'content': instance.content,
};
@@ -0,0 +1,15 @@
import '../../marianumconnect_query.dart';
import 'get_ticker_nav_response.dart';
/// Fetches the filtered ticker page tree from
/// `GET /api/mobile/v1/ticker/pages`.
class GetTickerNav extends MarianumConnectQuery {
GetTickerNav({super.dio});
Future<TickerNavResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
});
}
@@ -0,0 +1,78 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_nav_response.g.dart';
/// The filtered ticker page tree from `GET /api/mobile/v1/ticker/pages`.
///
/// The backend already drops unpublished pages, pages the user may not see, and
/// CONTENT pages without ProseMirror JSON — the app renders whatever arrives.
@JsonSerializable(explicitToJson: true)
class TickerNavResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
@JsonKey(defaultValue: '')
final String navHash;
@JsonKey(defaultValue: [])
final List<TickerNavSection> sections;
TickerNavResponse({
required this.schemaVersion,
required this.navHash,
required this.sections,
});
factory TickerNavResponse.fromJson(Map<String, dynamic> json) =>
_$TickerNavResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerNavResponseToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TickerNavSection {
@JsonKey(defaultValue: '')
final String title;
@JsonKey(defaultValue: [])
final List<TickerNavPage> pages;
TickerNavSection({required this.title, required this.pages});
factory TickerNavSection.fromJson(Map<String, dynamic> json) =>
_$TickerNavSectionFromJson(json);
Map<String, dynamic> toJson() => _$TickerNavSectionToJson(this);
}
/// A single navigable page. [kind] is one of `CONTENT`, `REDIRECT`,
/// `PROXIED_FILE` (see [TickerPageKind]); the extra fields are populated per
/// kind (REDIRECT → [externalUrl], PROXIED_FILE → [fileUrl]).
@JsonSerializable()
class TickerNavPage {
@JsonKey(defaultValue: '')
final String title;
@JsonKey(defaultValue: '')
final String slug;
@JsonKey(defaultValue: 'CONTENT')
final String kind;
final String? hash;
final String? externalUrl;
final bool? externalUrlNewTab;
final String? fileUrl;
TickerNavPage({
required this.title,
required this.slug,
required this.kind,
this.hash,
this.externalUrl,
this.externalUrlNewTab,
this.fileUrl,
});
factory TickerNavPage.fromJson(Map<String, dynamic> json) =>
_$TickerNavPageFromJson(json);
Map<String, dynamic> toJson() => _$TickerNavPageToJson(this);
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_nav_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
TickerNavResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
navHash: json['navHash'] as String? ?? '',
sections:
(json['sections'] as List<dynamic>?)
?.map((e) => TickerNavSection.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'navHash': instance.navHash,
'sections': instance.sections.map((e) => e.toJson()).toList(),
};
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
TickerNavSection(
title: json['title'] as String? ?? '',
pages:
(json['pages'] as List<dynamic>?)
?.map((e) => TickerNavPage.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
<String, dynamic>{
'title': instance.title,
'pages': instance.pages.map((e) => e.toJson()).toList(),
};
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
TickerNavPage(
title: json['title'] as String? ?? '',
slug: json['slug'] as String? ?? '',
kind: json['kind'] as String? ?? 'CONTENT',
hash: json['hash'] as String?,
externalUrl: json['externalUrl'] as String?,
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
fileUrl: json['fileUrl'] as String?,
);
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
<String, dynamic>{
'title': instance.title,
'slug': instance.slug,
'kind': instance.kind,
'hash': instance.hash,
'externalUrl': instance.externalUrl,
'externalUrlNewTab': instance.externalUrlNewTab,
'fileUrl': instance.fileUrl,
};
@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_query.dart';
import 'get_ticker_page_response.dart';
/// Fetches a single ticker page from
/// `GET /api/mobile/v1/ticker/pages/{slug}`.
///
/// A CONTENT page whose ProseMirror JSON hasn't been backfilled yet answers
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error. The bespoke 404 handling is why this keeps its own try/catch
/// instead of the base [guard].
class GetTickerPage extends MarianumConnectQuery {
final String slug;
GetTickerPage(this.slug, {super.dio});
Future<TickerPageResponse> run() async {
try {
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'),
);
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
throw _mapError(e);
}
}
Object _mapError(DioException e) {
final response = e.response;
if (response?.statusCode == 404) {
final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
final webUrl = body['webUrl'];
return TickerContentUnavailableException(
webUrl: webUrl is String ? webUrl : null,
technicalDetails: 'MC 404 CONTENT_UNAVAILABLE for slug=$slug',
);
}
}
return mapMarianumConnectError(e);
}
Map<String, dynamic>? _asMap(dynamic data) {
if (data is Map<String, dynamic>) return data;
if (data is String && data.isNotEmpty) {
try {
final decoded = jsonDecode(data);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {
return null;
}
}
return null;
}
}
@@ -0,0 +1,73 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_page_response.g.dart';
/// Canonical ticker page kinds. Kept as plain strings on the wire so an unknown
/// future kind never breaks JSON parsing.
abstract class TickerPageKind {
static const String content = 'CONTENT';
static const String redirect = 'REDIRECT';
static const String proxiedFile = 'PROXIED_FILE';
}
/// A single ticker page from `GET /api/mobile/v1/ticker/pages/{slug}`.
///
/// The populated fields depend on [kind]:
/// - `CONTENT` → [content] (nested ProseMirror document)
/// - `REDIRECT` → [externalUrl]
/// - `PROXIED_FILE` → [fileUrl], [contentType], [filename]
@JsonSerializable()
class TickerPageResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
final String? slug;
final String? title;
@JsonKey(defaultValue: 'CONTENT')
final String kind;
final Map<String, dynamic>? content;
final String? externalUrl;
final bool? externalUrlNewTab;
final String? fileUrl;
final String? contentType;
final String? filename;
final String? hash;
final String? webUrl;
/// ISO timestamp the page was last published/updated (`ticker_pages.published_at`),
/// shown as "Aktualisiert am …" — the same date the web view displays. Null
/// when the page has never been published.
final String? publishedAt;
/// PROXIED_FILE only: ISO timestamp of the last successful re-fetch of the
/// file by the server proxy (`ticker_pages.proxy_last_success_at`) — the
/// document's data currency, shown as "Aktualisiert am …" instead of
/// [publishedAt]. Null for other kinds / files never fetched yet.
final String? fileFetchedAt;
TickerPageResponse({
required this.schemaVersion,
this.slug,
this.title,
required this.kind,
this.content,
this.externalUrl,
this.externalUrlNewTab,
this.fileUrl,
this.contentType,
this.filename,
this.hash,
this.webUrl,
this.publishedAt,
this.fileFetchedAt,
});
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
_$TickerPageResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerPageResponseToJson(this);
}
@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_page_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
TickerPageResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
slug: json['slug'] as String?,
title: json['title'] as String?,
kind: json['kind'] as String? ?? 'CONTENT',
content: json['content'] as Map<String, dynamic>?,
externalUrl: json['externalUrl'] as String?,
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
fileUrl: json['fileUrl'] as String?,
contentType: json['contentType'] as String?,
filename: json['filename'] as String?,
hash: json['hash'] as String?,
webUrl: json['webUrl'] as String?,
publishedAt: json['publishedAt'] as String?,
fileFetchedAt: json['fileFetchedAt'] as String?,
);
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'slug': instance.slug,
'title': instance.title,
'kind': instance.kind,
'content': instance.content,
'externalUrl': instance.externalUrl,
'externalUrlNewTab': instance.externalUrlNewTab,
'fileUrl': instance.fileUrl,
'contentType': instance.contentType,
'filename': instance.filename,
'hash': instance.hash,
'webUrl': instance.webUrl,
'publishedAt': instance.publishedAt,
'fileFetchedAt': instance.fileFetchedAt,
};
@@ -0,0 +1,25 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
/// `GET /api/mobile/v1/ticker/pages/{slug}/file`.
///
/// Goes through the shared MC dio so the bearer token is attached automatically
/// (server enforces page visibility); the bytes are handed to `SfPdfViewer.memory`
/// so no auth header has to be plumbed into the viewer itself.
class GetTickerPageFile extends MarianumConnectQuery {
final String slug;
GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
}
@@ -1,17 +1,11 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
/// device-identifier signature, stores the routing metadata and starts
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
class PushDeviceRegister {
final Dio _dio;
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio});
Future<void> run({
required String deviceIdentifier,
@@ -21,24 +15,20 @@ class PushDeviceRegister {
required String platform,
required String registrationType,
String? appVersion,
}) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
'registrationType': registrationType,
'appVersion': ?appVersion,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
await dio.put<void>(
endpoint('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
'registrationType': registrationType,
'appVersion': ?appVersion,
},
);
});
}
@@ -1,25 +1,15 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Triggers a test push to all of the current user's registered devices via
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
/// backend dispatched to (0 when none are registered).
class PushDeviceTest {
final Dio _dio;
class PushDeviceTest extends MarianumConnectQuery {
PushDeviceTest({super.dio});
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<int> run() async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<int> run() => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
});
}
@@ -1,25 +1,15 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Removes this device's push subscription from MarianumConnect via
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
/// (204 even when the row is already gone).
class PushDeviceUnregister {
final Dio _dio;
class PushDeviceUnregister extends MarianumConnectQuery {
PushDeviceUnregister({super.dio});
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({required String deviceIdentifier}) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<void> run({required String deviceIdentifier}) => guard(() async {
await dio.delete<void>(
endpoint('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
});
}
@@ -1,17 +1,11 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Sends a single client-side error report to MarianumConnect
/// (`POST client-errors`). The endpoint is public, so reports that happen
/// before login are still captured; when a bearer token is present the shared
/// dio interceptor attaches it and the server attributes the report to that user.
class ReportClientError {
final Dio _dio;
ReportClientError({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class ReportClientError extends MarianumConnectQuery {
ReportClientError({super.dio});
Future<void> run({
required String errorType,
@@ -21,22 +15,18 @@ class ReportClientError {
String? platform,
String? appVersion,
String? deviceModel,
}) async {
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('client-errors'),
data: {
'errorType': errorType,
'message': ?message,
'stacktrace': ?stacktrace,
'context': ?context,
'platform': ?platform,
'appVersion': ?appVersion,
'deviceModel': ?deviceModel,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
await dio.post<void>(
endpoint('client-errors'),
data: {
'errorType': errorType,
'message': ?message,
'stacktrace': ?stacktrace,
'context': ?context,
'platform': ?platform,
'appVersion': ?appVersion,
'deviceModel': ?deviceModel,
},
);
});
}
@@ -3,46 +3,37 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
/// the bearer token identifies them.
class SubmitFeedback {
final Dio _dio;
SubmitFeedback({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class SubmitFeedback extends MarianumConnectQuery {
SubmitFeedback({super.dio});
Future<void> run({
required String message,
Uint8List? screenshot,
String screenshotContentType = 'image/png',
}) async {
try {
final package = await PackageInfo.fromPlatform();
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
await _dio.post<void>(
MarianumConnectEndpoint.resolve('me/feedback'),
data: {
'message': message,
'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(),
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(),
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final package = await PackageInfo.fromPlatform();
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
await dio.post<void>(
endpoint('me/feedback'),
data: {
'message': message,
'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(),
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(),
},
);
});
static String? _platform() {
if (Platform.isAndroid) return 'android';
@@ -3,72 +3,75 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.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.
class TelemetryHeartbeat {
final Dio _dio;
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
/// 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 extends MarianumConnectQuery {
TelemetryHeartbeat({super.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 {
try {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
Future<void> send({required bool notificationsEnabled}) => guard(() async {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
String? osVersion;
var raw = <String, dynamic>{};
if (Platform.isAndroid) {
platform = 'android';
final androidInfo = await info.androidInfo;
deviceModel = androidInfo.model;
osVersion = androidInfo.version.release;
raw = androidInfo.data;
} else if (Platform.isIOS) {
platform = 'ios';
final appleInfo = await info.iosInfo;
deviceModel = appleInfo.utsname.machine;
osVersion = appleInfo.systemVersion;
raw = appleInfo.data;
}
await _dio.post<void>(
MarianumConnectEndpoint.resolve('me/telemetry'),
data: {
'deviceIdentifier': deviceIdentifier,
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': deviceModel,
'osVersion': osVersion,
'deviceInfo': jsonEncode(raw),
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
var platform = 'unknown';
String? deviceModel;
String? osVersion;
var raw = <String, dynamic>{};
if (Platform.isAndroid) {
platform = 'android';
final androidInfo = await info.androidInfo;
deviceModel = androidInfo.model;
osVersion = androidInfo.version.release;
raw = androidInfo.data;
} else if (Platform.isIOS) {
platform = 'ios';
final appleInfo = await info.iosInfo;
deviceModel = appleInfo.utsname.machine;
osVersion = appleInfo.systemVersion;
raw = appleInfo.data;
}
}
await dio.post<void>(
endpoint('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),
'deviceModel': deviceModel,
'osVersion': osVersion,
'deviceInfo': jsonEncode(raw),
},
);
});
}
@@ -0,0 +1,68 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart';
import 'timetable_custom_events_add.dart';
/// One-time migration of the user's custom timetable events from the legacy
/// MHSL backend to Marianum-Connect. Runs at most once per install, guarded by
/// a persisted flag.
///
/// The MHSL identity is `sha512(username:password)` — computed here exactly as
/// the app always did, so the fetch matches the user's own events perfectly.
/// Each event is POSTed to Marianum-Connect and then deleted from MHSL, which
/// makes the whole run idempotent and resumable: a re-run after a mid-way
/// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration {
static const String _collection = 'MarianumMobile';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
const CustomEventsMigration._();
static Future<void> runOnce() async {
if (DemoMode.active) return;
if (await _isDone()) return;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
).run();
for (final event in response.events) {
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
} catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free.
log('Custom events migration failed, will retry on next launch: $e');
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
}
@@ -0,0 +1,13 @@
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../marianumconnect_query.dart';
class TimetableCustomEventsAdd extends MarianumConnectQuery {
TimetableCustomEventsAdd({super.dio});
Future<void> run(CustomTimetableEvent event) => guard(() async {
await dio.post<void>(
endpoint('timetable/custom-events'),
data: event.toJson(),
);
});
}
@@ -0,0 +1,15 @@
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../request_cache.dart';
import 'timetable_custom_events_get.dart';
class TimetableCustomEventsCache
extends SimpleCache<GetCustomTimetableEventResponse> {
TimetableCustomEventsCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheMinute,
loader: () => TimetableCustomEventsGet().run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -0,0 +1,13 @@
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../marianumconnect_query.dart';
class TimetableCustomEventsGet extends MarianumConnectQuery {
TimetableCustomEventsGet({super.dio});
Future<GetCustomTimetableEventResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/custom-events'),
);
return GetCustomTimetableEventResponse.fromJson(response.data!);
});
}
@@ -0,0 +1,9 @@
import '../../marianumconnect_query.dart';
class TimetableCustomEventsRemove extends MarianumConnectQuery {
TimetableCustomEventsRemove({super.dio});
Future<void> run(String id) => guard(() async {
await dio.delete<void>(endpoint('timetable/custom-events/$id'));
});
}
@@ -0,0 +1,13 @@
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../marianumconnect_query.dart';
class TimetableCustomEventsUpdate extends MarianumConnectQuery {
TimetableCustomEventsUpdate({super.dio});
Future<void> run(String id, CustomTimetableEvent event) => guard(() async {
await dio.put<void>(
endpoint('timetable/custom-events/$id'),
data: event.toJson(),
);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_classes_response.dart';
class TimetableGetClasses {
final Dio _dio;
class TimetableGetClasses extends MarianumConnectQuery {
TimetableGetClasses({super.dio});
TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetClassesResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/elements/classes'),
);
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetClassesResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/classes'),
);
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
});
}
@@ -1,35 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
import 'timetable_element_type.dart';
/// Fetches a foreign element's weekly timetable from
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
class TimetableGetElementWeek {
final Dio _dio;
TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class TimetableGetElementWeek extends MarianumConnectQuery {
TimetableGetElementWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required TimetableElementType type,
required int id,
required DateTime from,
required DateTime until,
}) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'),
queryParameters: {'from': _format(from), 'until': _format(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/${type.pathSegment}/$id'),
queryParameters: {'from': _format(from), 'until': _format(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
});
String _format(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays {
final Dio _dio;
class TimetableGetHolidays extends MarianumConnectQuery {
TimetableGetHolidays({super.dio});
TimetableGetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetHolidaysResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/holidays'),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetHolidaysResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/holidays'),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
});
}
@@ -1,26 +1,14 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_rooms_response.dart';
class TimetableGetRooms {
final Dio _dio;
class TimetableGetRooms extends MarianumConnectQuery {
TimetableGetRooms({super.dio});
TimetableGetRooms({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetRoomsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/rooms'),
);
final list = response.data!
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetRoomsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
final list = response.data!
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
});
}
@@ -1,23 +1,13 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_schoolyear_response.dart';
class TimetableGetSchoolyear {
final Dio _dio;
class TimetableGetSchoolyear extends MarianumConnectQuery {
TimetableGetSchoolyear({super.dio});
TimetableGetSchoolyear({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetSchoolyearResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_students_response.dart';
class TimetableGetStudents {
final Dio _dio;
class TimetableGetStudents extends MarianumConnectQuery {
TimetableGetStudents({super.dio});
TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetStudentsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/elements/students'),
);
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetStudentsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/students'),
);
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects {
final Dio _dio;
class TimetableGetSubjects extends MarianumConnectQuery {
TimetableGetSubjects({super.dio});
TimetableGetSubjects({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetSubjectsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/subjects'),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetSubjectsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/subjects'),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
});
}
@@ -10,10 +10,15 @@ class McSubject {
final String shortName;
final String longName;
/// Persönliche Fach-Farbe (Palette-Name aus [SubjectColor]), serverseitig pro
/// Nutzer gepflegt. `null`, wenn für dieses Fach keine Farbe gesetzt ist.
final String? color;
McSubject({
required this.id,
required this.shortName,
required this.longName,
this.color,
});
factory McSubject.fromJson(Map<String, dynamic> json) =>
@@ -10,12 +10,14 @@ McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
color: json['color'] as String?,
);
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
'color': instance.color,
};
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
@@ -1,29 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers {
final Dio _dio;
class TimetableGetTeachers extends MarianumConnectQuery {
TimetableGetTeachers({super.dio});
TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetTeachersResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/elements/teachers'),
);
final list = response.data!
.map(
(e) =>
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
)
.toList();
return TimetableGetTeachersResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetTeachersResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/teachers'),
);
final list = response.data!
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTeachersResponse(result: list);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid {
final Dio _dio;
class TimetableGetTimegrid extends MarianumConnectQuery {
TimetableGetTimegrid({super.dio});
TimetableGetTimegrid({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetTimegridResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/timegrid'),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetTimegridResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/timegrid'),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
});
}
@@ -1,32 +1,19 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_week_response.dart';
class TimetableGetWeek {
final Dio _dio;
TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class TimetableGetWeek extends MarianumConnectQuery {
TimetableGetWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required DateTime from,
required DateTime until,
}) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/me'),
queryParameters: {
'from': _format(from),
'until': _format(until),
},
);
return TimetableGetWeekResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/me'),
queryParameters: {'from': _format(from), 'until': _format(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
});
String _format(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -0,0 +1,14 @@
import '../../marianumconnect_query.dart';
/// Entfernt die persönliche Farbe eines Fachs (Fach fällt auf die Status-Farbe
/// zurück). Schlüssel ist das Fach-Kürzel (`shortName`).
class TimetableSubjectColorRemove extends MarianumConnectQuery {
TimetableSubjectColorRemove({super.dio});
Future<void> run(String subjectShort) => guard(() async {
await dio.delete<void>(
endpoint('timetable/subject-colors'),
queryParameters: {'subject': subjectShort},
);
});
}
@@ -0,0 +1,14 @@
import '../../marianumconnect_query.dart';
/// Setzt (oder überschreibt) die persönliche Farbe eines Fachs. Der Schlüssel
/// ist das Fach-Kürzel (`shortName`); die Farbe ist ein Palette-Name.
class TimetableSubjectColorSet extends MarianumConnectQuery {
TimetableSubjectColorSet({super.dio});
Future<void> run(String subjectShort, String color) => guard(() async {
await dio.put<void>(
endpoint('timetable/subject-colors'),
data: {'subject': subjectShort, 'color': color},
);
});
}
@@ -0,0 +1,20 @@
import '../../marianumconnect_query.dart';
import 'user_search_response.dart';
/// Searches active users (students, teachers, staff) via the MarianumConnect
/// mobile API. Returns each match's Nextcloud username plus role, so the Talk
/// search can start a direct chat and label results without hitting Nextcloud.
class UserSearch extends MarianumConnectQuery {
UserSearch({super.dio});
Future<UserSearchResponse> run(String query) => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('users/search'),
queryParameters: {'q': query},
);
final list = response.data!
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
.toList();
return UserSearchResponse(result: list);
});
}
@@ -0,0 +1,40 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'user_search_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McUserSearchResult {
/// Nextcloud-/Talk-Username — dient als `invite` beim Chat-Start.
final String username;
final String firstName;
final String lastName;
/// STUDENT, TEACHER oder STAFF.
final String userType;
final String? className;
McUserSearchResult({
required this.username,
required this.firstName,
required this.lastName,
required this.userType,
this.className,
});
factory McUserSearchResult.fromJson(Map<String, dynamic> json) =>
_$McUserSearchResultFromJson(json);
Map<String, dynamic> toJson() => _$McUserSearchResultToJson(this);
}
@JsonSerializable(explicitToJson: true)
class UserSearchResponse extends ApiResponse {
final List<McUserSearchResult> result;
UserSearchResponse({required this.result});
factory UserSearchResponse.fromJson(Map<String, dynamic> json) =>
_$UserSearchResponseFromJson(json);
Map<String, dynamic> toJson() => _$UserSearchResponseToJson(this);
}
@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_search_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McUserSearchResult _$McUserSearchResultFromJson(Map<String, dynamic> json) =>
McUserSearchResult(
username: json['username'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
userType: json['userType'] as String,
className: json['className'] as String?,
);
Map<String, dynamic> _$McUserSearchResultToJson(McUserSearchResult instance) =>
<String, dynamic>{
'username': instance.username,
'firstName': instance.firstName,
'lastName': instance.lastName,
'userType': instance.userType,
'className': instance.className,
};
UserSearchResponse _$UserSearchResponseFromJson(Map<String, dynamic> json) =>
UserSearchResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$UserSearchResponseToJson(UserSearchResponse instance) =>
<String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -1,22 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:http/http.dart' as http;
import '../../mhsl_api.dart';
import 'add_custom_timetable_event_params.dart';
class AddCustomTimetableEvent extends MhslApi<void> {
AddCustomTimetableEventParams params;
AddCustomTimetableEvent(this.params) : super('server/timetable/customEvents');
@override
void assemble(String raw) {}
@override
Future<Response>? request(Uri uri) {
var body = jsonEncode(params.toJson());
return http.post(uri, body: body);
}
}
@@ -1,17 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../custom_timetable_event.dart';
part 'add_custom_timetable_event_params.g.dart';
@JsonSerializable(explicitToJson: true)
class AddCustomTimetableEventParams {
String user;
CustomTimetableEvent event;
AddCustomTimetableEventParams(this.user, this.event);
factory AddCustomTimetableEventParams.fromJson(Map<String, dynamic> json) =>
_$AddCustomTimetableEventParamsFromJson(json);
Map<String, dynamic> toJson() => _$AddCustomTimetableEventParamsToJson(this);
}
@@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'add_custom_timetable_event_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AddCustomTimetableEventParams _$AddCustomTimetableEventParamsFromJson(
Map<String, dynamic> json,
) => AddCustomTimetableEventParams(
json['user'] as String,
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AddCustomTimetableEventParamsToJson(
AddCustomTimetableEventParams instance,
) => <String, dynamic>{'user': instance.user, 'event': instance.event.toJson()};
@@ -1,7 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
import '../mhsl_api.dart';
part 'custom_timetable_event.g.dart';
@JsonSerializable()
@@ -9,15 +7,23 @@ class CustomTimetableEvent {
String id;
String title;
String description;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime startDate;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime endDate;
String? color;
String rrule;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
/// When true, occurrences of a recurring event that fall inside a school
/// holiday are hidden in the calendar. Purely client-side: the backend only
/// stores the flag, the exclusion is applied at render time against the
/// holiday list. Ignored for non-recurring events (empty [rrule]).
@JsonKey(defaultValue: false)
bool skipHolidays;
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime createdAt;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime updatedAt;
CustomTimetableEvent({
@@ -28,6 +34,7 @@ class CustomTimetableEvent {
required this.endDate,
required this.color,
required this.rrule,
this.skipHolidays = false,
required this.createdAt,
required this.updatedAt,
});
@@ -35,4 +42,13 @@ class CustomTimetableEvent {
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
_$CustomTimetableEventFromJson(json);
Map<String, dynamic> toJson() => _$CustomTimetableEventToJson(this);
// Marianum-Connect serializes LocalDateTime as ISO-8601 (`yyyy-MM-ddTHH:mm:ss`)
// and its GSON parser rejects anything else. DateTime.parse still reads the
// old MHSL `yyyy-MM-dd HH:mm:ss` shape too, so events fetched during the
// one-time migration deserialize unchanged.
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
static String _dateToJson(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'
'T${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}:${d.second.toString().padLeft(2, '0')}';
}
@@ -12,12 +12,13 @@ CustomTimetableEvent _$CustomTimetableEventFromJson(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
startDate: MhslApi.dateTimeFromJson(json['startDate'] as String),
endDate: MhslApi.dateTimeFromJson(json['endDate'] as String),
startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String),
endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String),
color: json['color'] as String?,
rrule: json['rrule'] as String,
createdAt: MhslApi.dateTimeFromJson(json['createdAt'] as String),
updatedAt: MhslApi.dateTimeFromJson(json['updatedAt'] as String),
skipHolidays: json['skipHolidays'] as bool? ?? false,
createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String),
updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String),
);
Map<String, dynamic> _$CustomTimetableEventToJson(
@@ -26,10 +27,11 @@ Map<String, dynamic> _$CustomTimetableEventToJson(
'id': instance.id,
'title': instance.title,
'description': instance.description,
'startDate': MhslApi.dateTimeToJson(instance.startDate),
'endDate': MhslApi.dateTimeToJson(instance.endDate),
'startDate': CustomTimetableEvent._dateToJson(instance.startDate),
'endDate': CustomTimetableEvent._dateToJson(instance.endDate),
'color': instance.color,
'rrule': instance.rrule,
'createdAt': MhslApi.dateTimeToJson(instance.createdAt),
'updatedAt': MhslApi.dateTimeToJson(instance.updatedAt),
'skipHolidays': instance.skipHolidays,
'createdAt': CustomTimetableEvent._dateToJson(instance.createdAt),
'updatedAt': CustomTimetableEvent._dateToJson(instance.updatedAt),
};
@@ -1,20 +0,0 @@
import '../../../request_cache.dart';
import 'get_custom_timetable_event.dart';
import 'get_custom_timetable_event_params.dart';
import 'get_custom_timetable_event_response.dart';
class GetCustomTimetableEventCache
extends SimpleCache<GetCustomTimetableEventResponse> {
GetCustomTimetableEventCache(
GetCustomTimetableEventParams params, {
super.onUpdate,
super.onError,
super.renew,
}) : super(
cacheTime: RequestCache.cacheMinute,
loader: () => GetCustomTimetableEvent(params).run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -1,21 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:http/http.dart' as http;
import '../../mhsl_api.dart';
import 'update_custom_timetable_event_params.dart';
class UpdateCustomTimetableEvent extends MhslApi<void> {
UpdateCustomTimetableEventParams params;
UpdateCustomTimetableEvent(this.params)
: super('server/timetable/customEvents');
@override
void assemble(String raw) {}
@override
Future<Response>? request(Uri uri) =>
http.patch(uri, body: jsonEncode(params.toJson()));
}
@@ -1,19 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../custom_timetable_event.dart';
part 'update_custom_timetable_event_params.g.dart';
@JsonSerializable(explicitToJson: true)
class UpdateCustomTimetableEventParams {
String id;
CustomTimetableEvent event;
UpdateCustomTimetableEventParams(this.id, this.event);
factory UpdateCustomTimetableEventParams.fromJson(
Map<String, dynamic> json,
) => _$UpdateCustomTimetableEventParamsFromJson(json);
Map<String, dynamic> toJson() =>
_$UpdateCustomTimetableEventParamsToJson(this);
}
@@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'update_custom_timetable_event_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UpdateCustomTimetableEventParams _$UpdateCustomTimetableEventParamsFromJson(
Map<String, dynamic> json,
) => UpdateCustomTimetableEventParams(
json['id'] as String,
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$UpdateCustomTimetableEventParamsToJson(
UpdateCustomTimetableEventParams instance,
) => <String, dynamic>{'id': instance.id, 'event': instance.event.toJson()};
+22 -12
View File
@@ -93,6 +93,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationTasks.navigateToTalk(context, chatToken: token);
}
void _onNewsletterTapPending() {
final id = PushTapRouter.pendingNewsletterId.value;
if (id == null || !mounted) return;
PushTapRouter.pendingNewsletterId.value = null;
AppRoutes.openNewsletterById(context, id: id);
}
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
@@ -171,26 +178,28 @@ 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.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
@@ -221,6 +230,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
+3
View File
@@ -64,6 +64,9 @@ extension DateTimeFormatting on DateTime {
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
/// Compact `yyyyMMdd` key, e.g. to identify a week-start in timetable caches.
String weekKey() => Jiffy.parseFromDateTime(this).format(pattern: 'yyyyMMdd');
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
String formatDateRelativeShort({DateTime? now}) {
-10
View File
@@ -1,10 +0,0 @@
import 'package:flutter/material.dart';
extension TimeOfDayExt on TimeOfDay {
bool isBefore(TimeOfDay other) => hour < other.hour && minute < other.minute;
bool isAfter(TimeOfDay other) => hour > other.hour && minute > other.minute;
TimeOfDay add({int hours = 0, int minutes = 0}) =>
replacing(hour: hour + hours, minute: minute + minutes);
}
+50 -9
View File
@@ -22,6 +22,7 @@ import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart';
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
@@ -29,6 +30,7 @@ import 'model/account_data.dart';
import 'notification/notification_service.dart';
import 'push/push_message_handler.dart';
import 'push/push_registration.dart';
import 'push/push_registration_store.dart';
import 'push/push_renderer.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart';
@@ -49,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
import 'view/login/login.dart';
import 'view/login/post_login_splash.dart';
import 'widget/app_progress_indicator.dart';
import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
import 'widget/downloads/download_tray.dart';
@@ -119,10 +122,19 @@ Future<void> main() async {
),
);
// Diagnostic log only. getToken() can fail transiently during cold start
// (Android: "IOException: FCM Registration failed!" from Play Services,
// iOS: apns-token-not-set until APNS registration completes), so tolerate
// the failure instead of letting it surface as an uncaught async error that
// gets reported to the telemetry backend as noise.
unawaited(
FirebaseMessaging.instance.getToken().then(
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
),
FirebaseMessaging.instance
.getToken()
.then(
(token) =>
log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
)
.onError((e, _) => log('Firebase token unavailable: $e')),
);
// Warm up the Nextcloud root listing in the background while the user is
@@ -139,6 +151,11 @@ Future<void> main() async {
);
}
// Resolve the avatar cache directory ahead of the first avatar render so the
// synchronous disk read hits and cold-start avatars appear without a blank
// placeholder flash.
AvatarDiskCache.instance.warmUp();
if (kReleaseMode) {
ErrorWidget.builder = (error) => Material(
color: Colors.white,
@@ -251,14 +268,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 +316,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:
@@ -335,9 +368,17 @@ class _MainState extends State<Main> {
previous.status != current.status,
listener: (context, accountState) {
// Fresh login (loggedOut -> loggedIn): pull capability flags
// for the newly authenticated user.
// for the newly authenticated user, then register push right
// away instead of deferring it to the next app start.
if (accountState.status == AccountStatus.loggedIn) {
unawaited(context.read<CapabilitiesCubit>().load());
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
}),
);
unawaited(
context.read<NextcloudCapabilitiesCubit>().load(),
);
-9
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -57,14 +56,6 @@ class AccountData {
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
.toString();
Future<String> getDeviceId() async => sha512
.convert(
utf8.encode(
'${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}',
),
)
.toString();
Future<void> setData(String username, String password) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(key: _passwordField, value: password);
+1 -1
View File
@@ -9,7 +9,7 @@ class DataCleaner {
.get();
cacheData?.forEach((key, value) async {
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
value['lastupdate'] as int,
((value['lastupdate'] as num?) ?? 0).toInt(),
);
if (DateTime.now()
.subtract(const Duration(days: 200))
+19 -4
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart';
import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart';
@@ -41,10 +42,19 @@ class NotificationController {
RemoteMessage message,
BuildContext context,
) async {
NotificationTasks.navigateToTalk(
context,
chatToken: _extractChatToken(message),
);
final newsletterId = _extractNewsletterId(message);
if (newsletterId != null) {
AppRoutes.openNewsletterById(
context,
id: newsletterId,
title: message.notification?.title,
);
} else {
NotificationTasks.navigateToTalk(
context,
chatToken: _extractChatToken(message),
);
}
NotificationTasks.updateProviders(context);
unawaited(NotificationTasks.refreshBadge());
@@ -66,4 +76,9 @@ class NotificationController {
}
return null;
}
static String? _extractNewsletterId(RemoteMessage message) {
final value = message.data['newsletterId'];
return value is String && value.isNotEmpty ? value : null;
}
}
+20 -1
View File
@@ -77,15 +77,24 @@ class PushMessageHandler {
String? openChatToken,
}) async {
final data = message.data;
// The device stays registered even when the user turns notifications off,
// so silent sync pushes (deletes, data refresh) keep arriving. When off we
// still process the message but skip raising a visible notification.
final notificationsEnabled = await _registrationStore.notificationsEnabled();
switch (classifyPush(data)) {
case PushKind.connect:
await _handleConnect(message, foreground: foreground);
await _handleConnect(
message,
foreground: foreground,
notificationsEnabled: notificationsEnabled,
);
break;
case PushKind.nextcloud:
await _handleNextcloud(
data,
foreground: foreground,
openChatToken: openChatToken,
notificationsEnabled: notificationsEnabled,
);
break;
case PushKind.unknown:
@@ -96,7 +105,11 @@ class PushMessageHandler {
Future<void> _handleConnect(
RemoteMessage message, {
required bool foreground,
required bool notificationsEnabled,
}) async {
// Connect pushes carry no silent side effects, so nothing to do when the
// user has notifications off.
if (!notificationsEnabled) return;
// On iOS the alert is delivered natively by the system; only Android needs
// to render the plaintext payload locally.
final data = message.data;
@@ -114,6 +127,7 @@ class PushMessageHandler {
Map<String, dynamic> data, {
required bool foreground,
required String? openChatToken,
required bool notificationsEnabled,
}) async {
final subjectBase64 = data['subject'] as String;
final signatureBase64 = data['signature'] as String;
@@ -153,6 +167,11 @@ class PushMessageHandler {
return;
}
// Notifications turned off: the push was still processed (deletes above,
// plus the foreground badge/provider refresh in NotificationController) —
// only the visible tray notification is suppressed.
if (!notificationsEnabled) return;
await _renderer.render(subject);
}
+19 -13
View File
@@ -350,21 +350,26 @@ class PushRegistration {
}
}
/// Registers this device when push is both user-enabled and backend-capable.
/// Only registers when the OS notification permission is *already* granted —
/// it never triggers the OS prompt itself. Requesting the permission is the
/// job of the first Talk visit (see `maybePromptTalkNotifications`), which
/// keeps the prompt out of the cold-start path. Safe to call on every start —
/// Nextcloud dedups an unchanged registration — which also self-heals a
/// device whose registration was lost.
static Future<void> syncSubscription({
required bool enabled,
required bool capable,
}) async {
if (!(enabled && capable)) return;
/// Registers this device whenever the backend advertises the push capability.
/// Deliberately independent of the in-app notification toggle: a user who
/// turned notifications off stays registered so silent sync pushes keep
/// flowing — the display is suppressed downstream via the mirrored flag (see
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
/// notification permission is *already* granted — it never triggers the OS
/// prompt itself. Requesting the permission is the job of the first Talk visit
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
/// cold-start path. Safe to call on every start — Nextcloud dedups an
/// unchanged registration — which also self-heals a device whose registration
/// was lost.
/// Returns whether registration was actually *attempted* (all gates passed).
/// Even a partial success persists the `general` device identifier, so the
/// caller re-emits telemetry on `true` to reflect the fresh registration in
/// the same session instead of lagging until the next launch.
static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false;
if (!await isOsPermissionGranted()) {
log('Push: OS notification permission not granted, skipping registration');
return;
return false;
}
final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check
@@ -373,6 +378,7 @@ class PushRegistration {
log('Push: registered endpoints outdated, re-registering');
}
await registration.register();
return true;
}
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
+18
View File
@@ -27,6 +27,11 @@ class PushRegistrationStore {
// (AccountData writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
// written group-scoped so the FCM background isolate (no bloc access) and the
// iOS NSE can gate rendering. The device stays *registered* when off so silent
// sync pushes keep flowing — only the visible alert is suppressed.
static const _notificationsEnabledKey = 'push_notifications_enabled';
static const _perTypeKeys = [
_deviceIdentifierKey,
@@ -82,6 +87,19 @@ class PushRegistrationStore {
await _storage.write(key: _baseUrlKey, value: baseUrl);
}
/// Mirrors the in-app notification toggle so the background isolate / iOS NSE
/// can read it without bloc access.
Future<void> setNotificationsEnabled(bool enabled) =>
_storage.write(
key: _notificationsEnabledKey,
value: enabled ? '1' : '0',
);
/// The mirrored notification toggle. Defaults to `true` when unset (fresh
/// install / pre-mirror build) so a missing mirror never silences pushes.
Future<bool> notificationsEnabled() async =>
await _storage.read(key: _notificationsEnabledKey) != '0';
Future<String?> deviceIdentifier(PushRegistrationType type) =>
_storage.read(key: keyFor(_deviceIdentifierKey, type));

Some files were not shown because too many files have changed in this diff Show More