Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15791423ea | |||
| 2f5a6b4ce0 | |||
| 53bc6d5360 | |||
| f50359b4eb | |||
| 9994a1f3fa | |||
| 4aa31a2e44 | |||
| dfce3e7b5c | |||
| 564a334cdc | |||
| db329c7299 | |||
| 0a2ff5c3fb | |||
| 9b5198c6db | |||
| 94794ff092 | |||
| fe2b3c43b2 | |||
| a7111844b1 | |||
| babc347b18 | |||
| 44e45c9b78 | |||
| 3f44e9302f | |||
| 59501d3b45 | |||
| 91a6216f66 | |||
| c444ed54a5 | |||
| ff23199345 | |||
| 9545d2a946 | |||
| 37608e59b3 | |||
| 0d01f6b631 | |||
| b0d2e7a34b | |||
| cedeb06569 | |||
| 1114291313 |
+12
@@ -350,3 +350,15 @@ hs_err_pid*
|
|||||||
|
|
||||||
*.idea*
|
*.idea*
|
||||||
**/.DS_store
|
**/.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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
|
||||||
|
# Plugin-Handling initialisiert.
|
||||||
@@ -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`).
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.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
|
/// Demo fixtures for the info messages — a single friendly welcome entry so the
|
||||||
@@ -6,16 +8,16 @@ class DemoMarianumMessage {
|
|||||||
const DemoMarianumMessage._();
|
const DemoMarianumMessage._();
|
||||||
|
|
||||||
static MarianumMessageList list() {
|
static MarianumMessageList list() {
|
||||||
final today = DateTime.now();
|
final date = DateFormat.yMMMM('de').format(DateTime.now());
|
||||||
final date =
|
|
||||||
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
|
|
||||||
|
|
||||||
return MarianumMessageList(
|
return MarianumMessageList(
|
||||||
base: 'https://marianum-fulda.de',
|
base: 'https://marianum-fulda.de',
|
||||||
messages: [
|
messages: [
|
||||||
MarianumMessage(
|
MarianumMessage(
|
||||||
|
id: 'demo',
|
||||||
name: 'Willkommen in der Marianum-App',
|
name: 'Willkommen in der Marianum-App',
|
||||||
date: date,
|
date: date,
|
||||||
|
description: 'Eine kurze Begrüßung zum Ausprobieren.',
|
||||||
url: 'https://marianum-fulda.de',
|
url: 'https://marianum-fulda.de',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'data/demo_breaker.dart';
|
import 'data/demo_breaker.dart';
|
||||||
import 'data/demo_holidays.dart';
|
import 'data/demo_holidays.dart';
|
||||||
import 'data/demo_timetable.dart';
|
import 'data/demo_timetable.dart';
|
||||||
|
import 'data/demo_users.dart';
|
||||||
|
|
||||||
/// Single source of truth for the MarianumConnect demo responses. The demo
|
/// Single source of truth for the MarianumConnect demo responses. The demo
|
||||||
/// interceptor asks this for the body of any MC request. Read endpoints reuse
|
/// 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();
|
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||||
case 'holidays':
|
case 'holidays':
|
||||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||||
|
case 'users/search':
|
||||||
|
return DemoUsers.search(query['q']?.toString() ?? '')
|
||||||
|
.map((e) => e.toJson())
|
||||||
|
.toList();
|
||||||
case 'breaker':
|
case 'breaker':
|
||||||
return DemoBreaker.none().toJson();
|
return DemoBreaker.none().toJson();
|
||||||
case 'timetable/elements/teachers':
|
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' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'create_room_params.dart';
|
import 'create_room_params.dart';
|
||||||
|
import 'create_room_response.dart';
|
||||||
|
|
||||||
class CreateRoom extends TalkApi {
|
class CreateRoom extends TalkApi<CreateRoomResponse> {
|
||||||
CreateRoomParams params;
|
CreateRoomParams params;
|
||||||
|
|
||||||
CreateRoom(this.params) : super('v4/room', params);
|
CreateRoom(this.params) : super('v4/room', params);
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
Future<Response>? request(
|
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';
|
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)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetPollStateResponse extends ApiResponse {
|
class GetPollStateResponse extends ApiResponse {
|
||||||
GetPollStateResponseObject data;
|
GetPollStateResponseObject data;
|
||||||
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
|
|||||||
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
|
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||||
_$GetPollStateResponseObjectFromJson(json);
|
_$GetPollStateResponseObjectFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
|
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.
|
// Token mitschicken statt ein eigenes 401 einzufangen.
|
||||||
final pending = _pendingReLogin;
|
final pending = _pendingReLogin;
|
||||||
if (pending != null) await pending;
|
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) {
|
if (token != null && token.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
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
|
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
/// separate from `AccountData` because the username/password live on (Nextcloud
|
||||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||||
@@ -11,7 +19,7 @@ class MarianumConnectTokenStorage {
|
|||||||
final FlutterSecureStorage _storage;
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
const MarianumConnectTokenStorage([
|
const MarianumConnectTokenStorage([
|
||||||
this._storage = const FlutterSecureStorage(),
|
this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
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 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'auth_login_response.dart';
|
import 'auth_login_response.dart';
|
||||||
|
|
||||||
/// Performs the Marianum-Connect bearer login. Used both by the foreground
|
/// 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*
|
/// 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
|
/// 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.
|
/// 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 _connectTimeout = Duration(seconds: 10);
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthLogin({
|
AuthLogin({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
_dio =
|
super(dio: dio ?? _buildDio());
|
||||||
dio ??
|
|
||||||
Dio(
|
static Dio _buildDio() => Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: _connectTimeout,
|
connectTimeout: _connectTimeout,
|
||||||
receiveTimeout: _receiveTimeout,
|
receiveTimeout: _receiveTimeout,
|
||||||
sendTimeout: _connectTimeout,
|
sendTimeout: _connectTimeout,
|
||||||
responseType: ResponseType.json,
|
responseType: ResponseType.json,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<AuthLoginResponse> run({
|
Future<AuthLoginResponse> run({
|
||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
required String tokenName,
|
required String tokenName,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
endpoint('auth/login'),
|
||||||
MarianumConnectEndpoint.resolve('auth/login'),
|
data: {
|
||||||
data: {
|
'username': username,
|
||||||
'username': username,
|
'password': password,
|
||||||
'password': password,
|
'tokenName': tokenName,
|
||||||
'tokenName': tokenName,
|
},
|
||||||
},
|
);
|
||||||
);
|
final payload = AuthLoginResponse.fromJson(response.data!);
|
||||||
final payload = AuthLoginResponse.fromJson(response.data!);
|
await _tokenStorage.write(
|
||||||
await _tokenStorage.write(
|
token: payload.token,
|
||||||
token: payload.token,
|
tokenId: payload.tokenId,
|
||||||
tokenId: payload.tokenId,
|
expiresAt: payload.expiresAt,
|
||||||
expiresAt: payload.expiresAt,
|
);
|
||||||
);
|
return payload;
|
||||||
return payload;
|
});
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
/// 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
|
/// — a network error still clears the local token so the user isn't stuck with
|
||||||
/// an unusable session.
|
/// an unusable session.
|
||||||
class AuthLogout {
|
class AuthLogout extends MarianumConnectQuery {
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthLogout({
|
AuthLogout({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
super.dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage;
|
||||||
_dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
try {
|
try {
|
||||||
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
|
await dio.post<void>(endpoint('auth/logout'));
|
||||||
} on DioException catch (_) {
|
} on DioException catch (_) {
|
||||||
// ignore — local clear below still happens
|
// ignore — local clear below still happens
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../../errors/auth_exception.dart';
|
import '../../../errors/auth_exception.dart';
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Probes that the stored bearer token still maps to the given credentials.
|
/// 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
|
/// 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
|
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||||
/// and obscure a real 401 with a silent re-login.
|
/// 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 _connectTimeout = Duration(seconds: 10);
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthVerify({
|
AuthVerify({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
_dio =
|
super(dio: dio ?? _buildDio());
|
||||||
dio ??
|
|
||||||
Dio(
|
static Dio _buildDio() => Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
connectTimeout: _connectTimeout,
|
connectTimeout: _connectTimeout,
|
||||||
sendTimeout: _connectTimeout,
|
sendTimeout: _connectTimeout,
|
||||||
receiveTimeout: _receiveTimeout,
|
receiveTimeout: _receiveTimeout,
|
||||||
responseType: ResponseType.json,
|
responseType: ResponseType.json,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
||||||
/// user, token missing, or token rejected), other [AppException]s on
|
/// user, token missing, or token rejected), other [AppException]s on
|
||||||
@@ -49,14 +47,12 @@ class AuthVerify {
|
|||||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
technicalDetails: 'AuthVerify: no bearer token in storage',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
return guard(() async {
|
||||||
await _dio.post<void>(
|
await dio.post<void>(
|
||||||
MarianumConnectEndpoint.resolve('auth/verify'),
|
endpoint('auth/verify'),
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_breakers_response.dart';
|
import 'get_breakers_response.dart';
|
||||||
|
|
||||||
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
|
/// 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
|
/// 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).
|
/// required, so this also works before login (e.g. to block the whole app).
|
||||||
class GetBreakers {
|
class GetBreakers extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetBreakers({super.dio});
|
||||||
|
|
||||||
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<GetBreakersResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
|
||||||
Future<GetBreakersResponse> run() async {
|
return GetBreakersResponse.fromJson(response.data!);
|
||||||
try {
|
});
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('breaker'),
|
|
||||||
);
|
|
||||||
return GetBreakersResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ enum BreakerArea {
|
|||||||
files,
|
files,
|
||||||
@JsonValue('NEWS')
|
@JsonValue('NEWS')
|
||||||
news,
|
news,
|
||||||
|
@JsonValue('TICKER')
|
||||||
|
ticker,
|
||||||
@JsonValue('ROOMPLAN')
|
@JsonValue('ROOMPLAN')
|
||||||
roomPlan,
|
roomPlan,
|
||||||
@JsonValue('GRADES')
|
@JsonValue('GRADES')
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
|
|||||||
BreakerArea.talk: 'TALK',
|
BreakerArea.talk: 'TALK',
|
||||||
BreakerArea.files: 'FILES',
|
BreakerArea.files: 'FILES',
|
||||||
BreakerArea.news: 'NEWS',
|
BreakerArea.news: 'NEWS',
|
||||||
|
BreakerArea.ticker: 'TICKER',
|
||||||
BreakerArea.roomPlan: 'ROOMPLAN',
|
BreakerArea.roomPlan: 'ROOMPLAN',
|
||||||
BreakerArea.grades: 'GRADES',
|
BreakerArea.grades: 'GRADES',
|
||||||
BreakerArea.holidays: 'HOLIDAYS',
|
BreakerArea.holidays: 'HOLIDAYS',
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_capabilities_response.dart';
|
import 'get_capabilities_response.dart';
|
||||||
|
|
||||||
/// Fetches the current user's mobile capability flags from
|
/// Fetches the current user's mobile capability flags from
|
||||||
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
||||||
/// so the bearer token is attached automatically.
|
/// so the bearer token is attached automatically.
|
||||||
class GetCapabilities {
|
class GetCapabilities extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetCapabilities({super.dio});
|
||||||
|
|
||||||
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<CapabilitiesResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
Future<CapabilitiesResponse> run() async {
|
endpoint('me/capabilities'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
return CapabilitiesResponse.fromJson(response.data!);
|
||||||
MarianumConnectEndpoint.resolve('me/capabilities'),
|
});
|
||||||
);
|
|
||||||
return CapabilitiesResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import '../../models/mc_holiday.dart';
|
import '../../models/mc_holiday.dart';
|
||||||
|
|
||||||
class GetHolidays {
|
class GetHolidays extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetHolidays({super.dio});
|
||||||
|
|
||||||
GetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<List<McHoliday>> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
|
||||||
Future<List<McHoliday>> run() async {
|
return response.data!
|
||||||
try {
|
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||||
final response = await _dio.get<List<dynamic>>(
|
.toList();
|
||||||
MarianumConnectEndpoint.resolve('holidays'),
|
});
|
||||||
);
|
|
||||||
return response.data!
|
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
||||||
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
||||||
/// device-identifier signature, stores the routing metadata and starts
|
/// device-identifier signature, stores the routing metadata and starts
|
||||||
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
||||||
class PushDeviceRegister {
|
class PushDeviceRegister extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
PushDeviceRegister({super.dio});
|
||||||
|
|
||||||
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String deviceIdentifier,
|
required String deviceIdentifier,
|
||||||
@@ -21,24 +15,20 @@ class PushDeviceRegister {
|
|||||||
required String platform,
|
required String platform,
|
||||||
required String registrationType,
|
required String registrationType,
|
||||||
String? appVersion,
|
String? appVersion,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
await dio.put<void>(
|
||||||
await _dio.put<void>(
|
endpoint('me/push-device'),
|
||||||
MarianumConnectEndpoint.resolve('me/push-device'),
|
data: {
|
||||||
data: {
|
'deviceIdentifier': deviceIdentifier,
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifierSignature': deviceIdentifierSignature,
|
||||||
'deviceIdentifierSignature': deviceIdentifierSignature,
|
'userPublicKey': userPublicKey,
|
||||||
'userPublicKey': userPublicKey,
|
'pushToken': pushToken,
|
||||||
'pushToken': pushToken,
|
'platform': platform,
|
||||||
'platform': platform,
|
// 'general' | 'talk' — the backend derives the NC hash comparison
|
||||||
// 'general' | 'talk' — the backend derives the NC hash comparison
|
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
|
||||||
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
|
'registrationType': registrationType,
|
||||||
'registrationType': registrationType,
|
'appVersion': ?appVersion,
|
||||||
'appVersion': ?appVersion,
|
},
|
||||||
},
|
);
|
||||||
);
|
});
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Triggers a test push to all of the current user's registered devices via
|
/// 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
|
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
|
||||||
/// backend dispatched to (0 when none are registered).
|
/// backend dispatched to (0 when none are registered).
|
||||||
class PushDeviceTest {
|
class PushDeviceTest extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
PushDeviceTest({super.dio});
|
||||||
|
|
||||||
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<int> run() => guard(() async {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
Future<int> run() async {
|
endpoint('me/push-device/test'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
return (response.data?['devices'] as num?)?.toInt() ?? 0;
|
||||||
MarianumConnectEndpoint.resolve('me/push-device/test'),
|
});
|
||||||
);
|
|
||||||
return (response.data?['devices'] as num?)?.toInt() ?? 0;
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Removes this device's push subscription from MarianumConnect via
|
/// Removes this device's push subscription from MarianumConnect via
|
||||||
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
|
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
|
||||||
/// (204 even when the row is already gone).
|
/// (204 even when the row is already gone).
|
||||||
class PushDeviceUnregister {
|
class PushDeviceUnregister extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
PushDeviceUnregister({super.dio});
|
||||||
|
|
||||||
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<void> run({required String deviceIdentifier}) => guard(() async {
|
||||||
|
await dio.delete<void>(
|
||||||
Future<void> run({required String deviceIdentifier}) async {
|
endpoint('me/push-device'),
|
||||||
try {
|
queryParameters: {'deviceIdentifier': deviceIdentifier},
|
||||||
await _dio.delete<void>(
|
);
|
||||||
MarianumConnectEndpoint.resolve('me/push-device'),
|
});
|
||||||
queryParameters: {'deviceIdentifier': deviceIdentifier},
|
|
||||||
);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Sends a single client-side error report to MarianumConnect
|
/// Sends a single client-side error report to MarianumConnect
|
||||||
/// (`POST client-errors`). The endpoint is public, so reports that happen
|
/// (`POST client-errors`). The endpoint is public, so reports that happen
|
||||||
/// before login are still captured; when a bearer token is present the shared
|
/// 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.
|
/// dio interceptor attaches it and the server attributes the report to that user.
|
||||||
class ReportClientError {
|
class ReportClientError extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
ReportClientError({super.dio});
|
||||||
|
|
||||||
ReportClientError({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String errorType,
|
required String errorType,
|
||||||
@@ -21,22 +15,18 @@ class ReportClientError {
|
|||||||
String? platform,
|
String? platform,
|
||||||
String? appVersion,
|
String? appVersion,
|
||||||
String? deviceModel,
|
String? deviceModel,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
await dio.post<void>(
|
||||||
await _dio.post<void>(
|
endpoint('client-errors'),
|
||||||
MarianumConnectEndpoint.resolve('client-errors'),
|
data: {
|
||||||
data: {
|
'errorType': errorType,
|
||||||
'errorType': errorType,
|
'message': ?message,
|
||||||
'message': ?message,
|
'stacktrace': ?stacktrace,
|
||||||
'stacktrace': ?stacktrace,
|
'context': ?context,
|
||||||
'context': ?context,
|
'platform': ?platform,
|
||||||
'platform': ?platform,
|
'appVersion': ?appVersion,
|
||||||
'appVersion': ?appVersion,
|
'deviceModel': ?deviceModel,
|
||||||
'deviceModel': ?deviceModel,
|
},
|
||||||
},
|
);
|
||||||
);
|
});
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,46 +3,37 @@ import 'dart:io';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
||||||
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
|
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
|
||||||
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
|
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
|
||||||
/// the bearer token identifies them.
|
/// the bearer token identifies them.
|
||||||
class SubmitFeedback {
|
class SubmitFeedback extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
SubmitFeedback({super.dio});
|
||||||
|
|
||||||
SubmitFeedback({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String message,
|
required String message,
|
||||||
Uint8List? screenshot,
|
Uint8List? screenshot,
|
||||||
String screenshotContentType = 'image/png',
|
String screenshotContentType = 'image/png',
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final package = await PackageInfo.fromPlatform();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
|
||||||
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
|
await dio.post<void>(
|
||||||
await _dio.post<void>(
|
endpoint('me/feedback'),
|
||||||
MarianumConnectEndpoint.resolve('me/feedback'),
|
data: {
|
||||||
data: {
|
'message': message,
|
||||||
'message': message,
|
'screenshot': ?screenshotBase64,
|
||||||
'screenshot': ?screenshotBase64,
|
'screenshotContentType': screenshot != null ? screenshotContentType : null,
|
||||||
'screenshotContentType': screenshot != null ? screenshotContentType : null,
|
'platform': _platform(),
|
||||||
'platform': _platform(),
|
'appVersion': package.version,
|
||||||
'appVersion': package.version,
|
'appBuild': int.tryParse(package.buildNumber),
|
||||||
'appBuild': int.tryParse(package.buildNumber),
|
'deviceModel': await _deviceModel(),
|
||||||
'deviceModel': await _deviceModel(),
|
},
|
||||||
},
|
);
|
||||||
);
|
});
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? _platform() {
|
static String? _platform() {
|
||||||
if (Platform.isAndroid) return 'android';
|
if (Platform.isAndroid) return 'android';
|
||||||
|
|||||||
@@ -3,72 +3,75 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../../../../push/push_registration_store.dart';
|
import '../../../../push/push_registration_store.dart';
|
||||||
import '../../../../push/push_registration_type.dart';
|
import '../../../../push/push_registration_type.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'telemetry_device_id.dart';
|
import 'telemetry_device_id.dart';
|
||||||
|
|
||||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one
|
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||||
/// upsert per app start carrying the stable install id, platform, app version
|
/// upserts the stable install id, platform, app version and device info. Sent
|
||||||
/// and device info. Bearer-authenticated via the shared dio interceptor.
|
/// once on app start and again once push registration completes that session
|
||||||
/// Replaces the legacy mhsl.eu `server/userIndex/update` call.
|
/// (so a fresh registration isn't under-reported until the next launch).
|
||||||
class TelemetryHeartbeat {
|
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||||
final Dio _dio;
|
/// mhsl.eu `server/userIndex/update` call.
|
||||||
|
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||||
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
TelemetryHeartbeat({super.dio});
|
||||||
|
|
||||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
||||||
/// send never disrupts app start. Used from the app shell's initState.
|
/// send never disrupts app start. Used from the app shell's initState and
|
||||||
static void report() {
|
/// re-emitted once push registration completes (see `_MainState._syncPush`).
|
||||||
unawaited(TelemetryHeartbeat().send().catchError((Object _) {}));
|
static void report({required bool notificationsEnabled}) {
|
||||||
|
unawaited(
|
||||||
|
TelemetryHeartbeat()
|
||||||
|
.send(notificationsEnabled: notificationsEnabled)
|
||||||
|
.catchError((Object _) {}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> send() async {
|
Future<void> send({required bool notificationsEnabled}) => guard(() async {
|
||||||
try {
|
final info = DeviceInfoPlugin();
|
||||||
final info = DeviceInfoPlugin();
|
final package = await PackageInfo.fromPlatform();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
||||||
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
final pushDeviceIdentifier = await const PushRegistrationStore()
|
||||||
final pushDeviceIdentifier = await const PushRegistrationStore()
|
.deviceIdentifier(PushRegistrationType.general);
|
||||||
.deviceIdentifier(PushRegistrationType.general);
|
|
||||||
|
|
||||||
var platform = 'unknown';
|
var platform = 'unknown';
|
||||||
String? deviceModel;
|
String? deviceModel;
|
||||||
String? osVersion;
|
String? osVersion;
|
||||||
var raw = <String, dynamic>{};
|
var raw = <String, dynamic>{};
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
platform = 'android';
|
platform = 'android';
|
||||||
final androidInfo = await info.androidInfo;
|
final androidInfo = await info.androidInfo;
|
||||||
deviceModel = androidInfo.model;
|
deviceModel = androidInfo.model;
|
||||||
osVersion = androidInfo.version.release;
|
osVersion = androidInfo.version.release;
|
||||||
raw = androidInfo.data;
|
raw = androidInfo.data;
|
||||||
} else if (Platform.isIOS) {
|
} else if (Platform.isIOS) {
|
||||||
platform = 'ios';
|
platform = 'ios';
|
||||||
final appleInfo = await info.iosInfo;
|
final appleInfo = await info.iosInfo;
|
||||||
deviceModel = appleInfo.utsname.machine;
|
deviceModel = appleInfo.utsname.machine;
|
||||||
osVersion = appleInfo.systemVersion;
|
osVersion = appleInfo.systemVersion;
|
||||||
raw = appleInfo.data;
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
|
import '../../../request_cache.dart';
|
||||||
|
import 'timetable_custom_events_get.dart';
|
||||||
|
|
||||||
|
class TimetableCustomEventsCache
|
||||||
|
extends SimpleCache<GetCustomTimetableEventResponse> {
|
||||||
|
TimetableCustomEventsCache({super.onUpdate, super.onError, super.renew})
|
||||||
|
: super(
|
||||||
|
cacheTime: RequestCache.cacheMinute,
|
||||||
|
loader: () => TimetableCustomEventsGet().run(),
|
||||||
|
fromJson: GetCustomTimetableEventResponse.fromJson,
|
||||||
|
) {
|
||||||
|
start('customTimetableEvents');
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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!);
|
||||||
|
});
|
||||||
|
}
|
||||||
+9
@@ -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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
+13
@@ -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 '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_classes_response.dart';
|
import 'timetable_get_classes_response.dart';
|
||||||
|
|
||||||
class TimetableGetClasses {
|
class TimetableGetClasses extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetClasses({super.dio});
|
||||||
|
|
||||||
TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetClassesResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetClassesResponse> run() async {
|
endpoint('timetable/elements/classes'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<List<dynamic>>(
|
final list = response.data!
|
||||||
MarianumConnectEndpoint.resolve('timetable/elements/classes'),
|
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final list = response.data!
|
return TimetableGetClassesResponse(result: list);
|
||||||
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
});
|
||||||
.toList();
|
|
||||||
return TimetableGetClassesResponse(result: list);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-20
@@ -1,35 +1,25 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import '../timetable_get_week/timetable_get_week_response.dart';
|
import '../timetable_get_week/timetable_get_week_response.dart';
|
||||||
import 'timetable_element_type.dart';
|
import 'timetable_element_type.dart';
|
||||||
|
|
||||||
/// Fetches a foreign element's weekly timetable from
|
/// Fetches a foreign element's weekly timetable from
|
||||||
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
|
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
|
||||||
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
|
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
|
||||||
class TimetableGetElementWeek {
|
class TimetableGetElementWeek extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetElementWeek({super.dio});
|
||||||
|
|
||||||
TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<TimetableGetWeekResponse> run({
|
Future<TimetableGetWeekResponse> run({
|
||||||
required TimetableElementType type,
|
required TimetableElementType type,
|
||||||
required int id,
|
required int id,
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime until,
|
required DateTime until,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
endpoint('timetable/${type.pathSegment}/$id'),
|
||||||
MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'),
|
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
);
|
||||||
);
|
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
});
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _format(DateTime d) =>
|
String _format(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
+12
-22
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_holidays_response.dart';
|
import 'timetable_get_holidays_response.dart';
|
||||||
|
|
||||||
class TimetableGetHolidays {
|
class TimetableGetHolidays extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetHolidays({super.dio});
|
||||||
|
|
||||||
TimetableGetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetHolidaysResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetHolidaysResponse> run() async {
|
endpoint('timetable/holidays'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<List<dynamic>>(
|
final list = response.data!
|
||||||
MarianumConnectEndpoint.resolve('timetable/holidays'),
|
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final list = response.data!
|
return TimetableGetHolidaysResponse(result: list);
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
});
|
||||||
.toList();
|
|
||||||
return TimetableGetHolidaysResponse(result: list);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_rooms_response.dart';
|
import 'timetable_get_rooms_response.dart';
|
||||||
|
|
||||||
class TimetableGetRooms {
|
class TimetableGetRooms extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetRooms({super.dio});
|
||||||
|
|
||||||
TimetableGetRooms({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetRoomsResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
|
||||||
Future<TimetableGetRoomsResponse> run() async {
|
final list = response.data!
|
||||||
try {
|
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
||||||
final response = await _dio.get<List<dynamic>>(
|
.toList();
|
||||||
MarianumConnectEndpoint.resolve('timetable/rooms'),
|
return TimetableGetRoomsResponse(result: list);
|
||||||
);
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-19
@@ -1,23 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_schoolyear_response.dart';
|
import 'timetable_get_schoolyear_response.dart';
|
||||||
|
|
||||||
class TimetableGetSchoolyear {
|
class TimetableGetSchoolyear extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetSchoolyear({super.dio});
|
||||||
|
|
||||||
TimetableGetSchoolyear({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
Future<TimetableGetSchoolyearResponse> run() async {
|
endpoint('timetable/schoolyear'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
||||||
MarianumConnectEndpoint.resolve('timetable/schoolyear'),
|
});
|
||||||
);
|
|
||||||
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-22
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_students_response.dart';
|
import 'timetable_get_students_response.dart';
|
||||||
|
|
||||||
class TimetableGetStudents {
|
class TimetableGetStudents extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetStudents({super.dio});
|
||||||
|
|
||||||
TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetStudentsResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetStudentsResponse> run() async {
|
endpoint('timetable/elements/students'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<List<dynamic>>(
|
final list = response.data!
|
||||||
MarianumConnectEndpoint.resolve('timetable/elements/students'),
|
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final list = response.data!
|
return TimetableGetStudentsResponse(result: list);
|
||||||
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
});
|
||||||
.toList();
|
|
||||||
return TimetableGetStudentsResponse(result: list);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-22
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_subjects_response.dart';
|
import 'timetable_get_subjects_response.dart';
|
||||||
|
|
||||||
class TimetableGetSubjects {
|
class TimetableGetSubjects extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetSubjects({super.dio});
|
||||||
|
|
||||||
TimetableGetSubjects({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetSubjectsResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetSubjectsResponse> run() async {
|
endpoint('timetable/subjects'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<List<dynamic>>(
|
final list = response.data!
|
||||||
MarianumConnectEndpoint.resolve('timetable/subjects'),
|
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final list = response.data!
|
return TimetableGetSubjectsResponse(result: list);
|
||||||
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
});
|
||||||
.toList();
|
|
||||||
return TimetableGetSubjectsResponse(result: list);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -10,10 +10,15 @@ class McSubject {
|
|||||||
final String shortName;
|
final String shortName;
|
||||||
final String longName;
|
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({
|
McSubject({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.shortName,
|
required this.shortName,
|
||||||
required this.longName,
|
required this.longName,
|
||||||
|
this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory McSubject.fromJson(Map<String, dynamic> json) =>
|
factory McSubject.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
+2
@@ -10,12 +10,14 @@ McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
|
|||||||
id: (json['id'] as num).toInt(),
|
id: (json['id'] as num).toInt(),
|
||||||
shortName: json['shortName'] as String,
|
shortName: json['shortName'] as String,
|
||||||
longName: json['longName'] as String,
|
longName: json['longName'] as String,
|
||||||
|
color: json['color'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
|
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'shortName': instance.shortName,
|
'shortName': instance.shortName,
|
||||||
'longName': instance.longName,
|
'longName': instance.longName,
|
||||||
|
'color': instance.color,
|
||||||
};
|
};
|
||||||
|
|
||||||
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
|
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
|
||||||
|
|||||||
+12
-25
@@ -1,29 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_teachers_response.dart';
|
import 'timetable_get_teachers_response.dart';
|
||||||
|
|
||||||
class TimetableGetTeachers {
|
class TimetableGetTeachers extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetTeachers({super.dio});
|
||||||
|
|
||||||
TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetTeachersResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetTeachersResponse> run() async {
|
endpoint('timetable/elements/teachers'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<List<dynamic>>(
|
final list = response.data!
|
||||||
MarianumConnectEndpoint.resolve('timetable/elements/teachers'),
|
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final list = response.data!
|
return TimetableGetTeachersResponse(result: list);
|
||||||
.map(
|
});
|
||||||
(e) =>
|
|
||||||
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
return TimetableGetTeachersResponse(result: list);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-22
@@ -1,26 +1,16 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_timegrid_response.dart';
|
import 'timetable_get_timegrid_response.dart';
|
||||||
|
|
||||||
class TimetableGetTimegrid {
|
class TimetableGetTimegrid extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetTimegrid({super.dio});
|
||||||
|
|
||||||
TimetableGetTimegrid({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<TimetableGetTimegridResponse> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
Future<TimetableGetTimegridResponse> run() async {
|
endpoint('timetable/timegrid'),
|
||||||
try {
|
);
|
||||||
final response = await _dio.get<List<dynamic>>(
|
final list = response.data!
|
||||||
MarianumConnectEndpoint.resolve('timetable/timegrid'),
|
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
||||||
);
|
.toList();
|
||||||
final list = response.data!
|
return TimetableGetTimegridResponse(result: list);
|
||||||
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
});
|
||||||
.toList();
|
|
||||||
return TimetableGetTimegridResponse(result: list);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'timetable_get_week_response.dart';
|
import 'timetable_get_week_response.dart';
|
||||||
|
|
||||||
class TimetableGetWeek {
|
class TimetableGetWeek extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
TimetableGetWeek({super.dio});
|
||||||
|
|
||||||
TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<TimetableGetWeekResponse> run({
|
Future<TimetableGetWeekResponse> run({
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime until,
|
required DateTime until,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
endpoint('timetable/me'),
|
||||||
MarianumConnectEndpoint.resolve('timetable/me'),
|
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||||
queryParameters: {
|
);
|
||||||
'from': _format(from),
|
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||||
'until': _format(until),
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _format(DateTime d) =>
|
String _format(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
+14
@@ -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},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
+14
@@ -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 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../mhsl_api.dart';
|
|
||||||
|
|
||||||
part 'custom_timetable_event.g.dart';
|
part 'custom_timetable_event.g.dart';
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
@@ -9,15 +7,23 @@ class CustomTimetableEvent {
|
|||||||
String id;
|
String id;
|
||||||
String title;
|
String title;
|
||||||
String description;
|
String description;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime startDate;
|
DateTime startDate;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime endDate;
|
DateTime endDate;
|
||||||
String? color;
|
String? color;
|
||||||
String rrule;
|
String rrule;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
|
||||||
|
/// When true, occurrences of a recurring event that fall inside a school
|
||||||
|
/// holiday are hidden in the calendar. Purely client-side: the backend only
|
||||||
|
/// stores the flag, the exclusion is applied at render time against the
|
||||||
|
/// holiday list. Ignored for non-recurring events (empty [rrule]).
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool skipHolidays;
|
||||||
|
|
||||||
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
|
||||||
CustomTimetableEvent({
|
CustomTimetableEvent({
|
||||||
@@ -28,6 +34,7 @@ class CustomTimetableEvent {
|
|||||||
required this.endDate,
|
required this.endDate,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.rrule,
|
required this.rrule,
|
||||||
|
this.skipHolidays = false,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -35,4 +42,13 @@ class CustomTimetableEvent {
|
|||||||
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
|
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
_$CustomTimetableEventFromJson(json);
|
_$CustomTimetableEventFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$CustomTimetableEventToJson(this);
|
Map<String, dynamic> toJson() => _$CustomTimetableEventToJson(this);
|
||||||
|
|
||||||
|
// Marianum-Connect serializes LocalDateTime as ISO-8601 (`yyyy-MM-ddTHH:mm:ss`)
|
||||||
|
// and its GSON parser rejects anything else. DateTime.parse still reads the
|
||||||
|
// old MHSL `yyyy-MM-dd HH:mm:ss` shape too, so events fetched during the
|
||||||
|
// one-time migration deserialize unchanged.
|
||||||
|
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
|
||||||
|
static String _dateToJson(DateTime d) =>
|
||||||
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'
|
||||||
|
'T${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}:${d.second.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ CustomTimetableEvent _$CustomTimetableEventFromJson(
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
description: json['description'] as String,
|
description: json['description'] as String,
|
||||||
startDate: MhslApi.dateTimeFromJson(json['startDate'] as String),
|
startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String),
|
||||||
endDate: MhslApi.dateTimeFromJson(json['endDate'] as String),
|
endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String),
|
||||||
color: json['color'] as String?,
|
color: json['color'] as String?,
|
||||||
rrule: json['rrule'] as String,
|
rrule: json['rrule'] as String,
|
||||||
createdAt: MhslApi.dateTimeFromJson(json['createdAt'] as String),
|
skipHolidays: json['skipHolidays'] as bool? ?? false,
|
||||||
updatedAt: MhslApi.dateTimeFromJson(json['updatedAt'] as String),
|
createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String),
|
||||||
|
updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CustomTimetableEventToJson(
|
Map<String, dynamic> _$CustomTimetableEventToJson(
|
||||||
@@ -26,10 +27,11 @@ Map<String, dynamic> _$CustomTimetableEventToJson(
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'title': instance.title,
|
'title': instance.title,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'startDate': MhslApi.dateTimeToJson(instance.startDate),
|
'startDate': CustomTimetableEvent._dateToJson(instance.startDate),
|
||||||
'endDate': MhslApi.dateTimeToJson(instance.endDate),
|
'endDate': CustomTimetableEvent._dateToJson(instance.endDate),
|
||||||
'color': instance.color,
|
'color': instance.color,
|
||||||
'rrule': instance.rrule,
|
'rrule': instance.rrule,
|
||||||
'createdAt': MhslApi.dateTimeToJson(instance.createdAt),
|
'skipHolidays': instance.skipHolidays,
|
||||||
'updatedAt': MhslApi.dateTimeToJson(instance.updatedAt),
|
'createdAt': CustomTimetableEvent._dateToJson(instance.createdAt),
|
||||||
|
'updatedAt': CustomTimetableEvent._dateToJson(instance.updatedAt),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import '../../../request_cache.dart';
|
|
||||||
import 'get_custom_timetable_event.dart';
|
|
||||||
import 'get_custom_timetable_event_params.dart';
|
|
||||||
import 'get_custom_timetable_event_response.dart';
|
|
||||||
|
|
||||||
class GetCustomTimetableEventCache
|
|
||||||
extends SimpleCache<GetCustomTimetableEventResponse> {
|
|
||||||
GetCustomTimetableEventCache(
|
|
||||||
GetCustomTimetableEventParams params, {
|
|
||||||
super.onUpdate,
|
|
||||||
super.onError,
|
|
||||||
super.renew,
|
|
||||||
}) : super(
|
|
||||||
cacheTime: RequestCache.cacheMinute,
|
|
||||||
loader: () => GetCustomTimetableEvent(params).run(),
|
|
||||||
fromJson: GetCustomTimetableEventResponse.fromJson,
|
|
||||||
) {
|
|
||||||
start('customTimetableEvents');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../mhsl_api.dart';
|
|
||||||
import 'update_custom_timetable_event_params.dart';
|
|
||||||
|
|
||||||
class UpdateCustomTimetableEvent extends MhslApi<void> {
|
|
||||||
UpdateCustomTimetableEventParams params;
|
|
||||||
|
|
||||||
UpdateCustomTimetableEvent(this.params)
|
|
||||||
: super('server/timetable/customEvents');
|
|
||||||
|
|
||||||
@override
|
|
||||||
void assemble(String raw) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Response>? request(Uri uri) =>
|
|
||||||
http.patch(uri, body: jsonEncode(params.toJson()));
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
import '../custom_timetable_event.dart';
|
|
||||||
|
|
||||||
part 'update_custom_timetable_event_params.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
|
||||||
class UpdateCustomTimetableEventParams {
|
|
||||||
String id;
|
|
||||||
CustomTimetableEvent event;
|
|
||||||
|
|
||||||
UpdateCustomTimetableEventParams(this.id, this.event);
|
|
||||||
|
|
||||||
factory UpdateCustomTimetableEventParams.fromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
) => _$UpdateCustomTimetableEventParamsFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() =>
|
|
||||||
_$UpdateCustomTimetableEventParamsToJson(this);
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'update_custom_timetable_event_params.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
UpdateCustomTimetableEventParams _$UpdateCustomTimetableEventParamsFromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
) => UpdateCustomTimetableEventParams(
|
|
||||||
json['id'] as String,
|
|
||||||
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$UpdateCustomTimetableEventParamsToJson(
|
|
||||||
UpdateCustomTimetableEventParams instance,
|
|
||||||
) => <String, dynamic>{'id': instance.id, 'event': instance.event.toJson()};
|
|
||||||
+22
-12
@@ -93,6 +93,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
NotificationTasks.navigateToTalk(context, chatToken: token);
|
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 {
|
Future<void> _handlePendingWidgetNavigation() async {
|
||||||
final pending = await WidgetNavigation.consumePendingTimetableTap();
|
final pending = await WidgetNavigation.consumePendingTimetableTap();
|
||||||
if (!pending || !mounted) return;
|
if (!pending || !mounted) return;
|
||||||
@@ -171,26 +178,28 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
TelemetryHeartbeat.report();
|
TelemetryHeartbeat.report(
|
||||||
|
notificationsEnabled:
|
||||||
|
context.read<SettingsCubit>().val().notificationSettings.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
// A refreshed FCM token invalidates the existing push subscription — the
|
// A refreshed FCM token invalidates the existing push subscription — the
|
||||||
// NC device identifier stays stable, so we simply re-register (NC first,
|
// NC device identifier stays stable, so we simply re-register (NC first,
|
||||||
// then the proxy). Debounced so a burst of refreshes triggers one call.
|
// then the proxy). Debounced so a burst of refreshes triggers one call.
|
||||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
// Not gated on the notification toggle: registration is kept alive even
|
||||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
|
// when notifications are off so silent sync pushes keep flowing.
|
||||||
_,
|
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((_) {
|
||||||
) {
|
Debouncer.debounce(
|
||||||
Debouncer.debounce(
|
'pushTokenRefresh',
|
||||||
'pushTokenRefresh',
|
const Duration(seconds: 3),
|
||||||
const Duration(seconds: 3),
|
() => unawaited(PushRegistration().onTokenRefresh()),
|
||||||
() => unawaited(PushRegistration().onTokenRefresh()),
|
);
|
||||||
);
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android renders pushes locally, so a tap arrives via the local
|
// Android renders pushes locally, so a tap arrives via the local
|
||||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
||||||
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
||||||
|
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
|
||||||
|
|
||||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -221,6 +230,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_onMessageOpenedAppSub?.cancel();
|
_onMessageOpenedAppSub?.cancel();
|
||||||
_fcmTokenRefreshSub?.cancel();
|
_fcmTokenRefreshSub?.cancel();
|
||||||
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
||||||
|
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
|
||||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
ShareIntentListener.instance.detach();
|
ShareIntentListener.instance.detach();
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ extension DateTimeFormatting on DateTime {
|
|||||||
|
|
||||||
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
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 timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
||||||
|
|
||||||
String formatDateRelativeShort({DateTime? now}) {
|
String formatDateRelativeShort({DateTime? now}) {
|
||||||
|
|||||||
@@ -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
@@ -22,6 +22,7 @@ import 'api/marianumconnect/auth/session_validator.dart';
|
|||||||
import 'api/marianumconnect/marianumconnect_endpoint.dart';
|
import 'api/marianumconnect/marianumconnect_endpoint.dart';
|
||||||
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||||
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
|
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
|
||||||
|
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
@@ -29,6 +30,7 @@ import 'model/account_data.dart';
|
|||||||
import 'notification/notification_service.dart';
|
import 'notification/notification_service.dart';
|
||||||
import 'push/push_message_handler.dart';
|
import 'push/push_message_handler.dart';
|
||||||
import 'push/push_registration.dart';
|
import 'push/push_registration.dart';
|
||||||
|
import 'push/push_registration_store.dart';
|
||||||
import 'push/push_renderer.dart';
|
import 'push/push_renderer.dart';
|
||||||
import 'routing/app_routes.dart';
|
import 'routing/app_routes.dart';
|
||||||
import 'share_intent/share_intent_listener.dart';
|
import 'share_intent/share_intent_listener.dart';
|
||||||
@@ -49,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
|
|||||||
import 'view/login/login.dart';
|
import 'view/login/login.dart';
|
||||||
import 'view/login/post_login_splash.dart';
|
import 'view/login/post_login_splash.dart';
|
||||||
import 'widget/app_progress_indicator.dart';
|
import 'widget/app_progress_indicator.dart';
|
||||||
|
import 'widget/avatar_disk_cache.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
import 'widget/downloads/download_tray.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(
|
unawaited(
|
||||||
FirebaseMessaging.instance.getToken().then(
|
FirebaseMessaging.instance
|
||||||
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
|
.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
|
// 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) {
|
if (kReleaseMode) {
|
||||||
ErrorWidget.builder = (error) => Material(
|
ErrorWidget.builder = (error) => Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -251,14 +268,23 @@ class _MainState extends State<Main> {
|
|||||||
unawaited(ListFilesCache.prefetchRootListing());
|
unawaited(ListFilesCache.prefetchRootListing());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers/self-heals the push subscription when push is user-enabled and
|
/// Registers/self-heals the push subscription whenever the backend advertises
|
||||||
/// the backend advertises the capability. Fire-and-forget.
|
/// the capability — independent of the notification toggle, so a user with
|
||||||
|
/// notifications off stays registered for silent sync pushes. Fire-and-forget.
|
||||||
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
|
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
|
||||||
|
final enabled = settings.val().notificationSettings.enabled;
|
||||||
unawaited(
|
unawaited(
|
||||||
PushRegistration.syncSubscription(
|
PushRegistration.syncSubscription(
|
||||||
enabled: settings.val().notificationSettings.enabled,
|
|
||||||
capable: capabilities.canReceivePushNotifications,
|
capable: capabilities.canReceivePushNotifications,
|
||||||
),
|
).then((registered) {
|
||||||
|
// The app-start heartbeat runs before this async registration
|
||||||
|
// finishes, so it reports the pre-registration state. Re-emit once
|
||||||
|
// the identifier is persisted so the new push status shows up this
|
||||||
|
// session instead of only after the next launch.
|
||||||
|
if (registered) {
|
||||||
|
TelemetryHeartbeat.report(notificationsEnabled: enabled);
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +316,13 @@ class _MainState extends State<Main> {
|
|||||||
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
|
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
|
||||||
MarianumConnectEndpoint.update(mcBaseUrl);
|
MarianumConnectEndpoint.update(mcBaseUrl);
|
||||||
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
|
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
|
||||||
|
// Mirror the notification toggle into group-scoped storage so the FCM
|
||||||
|
// background isolate and the iOS NSE can suppress rendering when off.
|
||||||
|
unawaited(
|
||||||
|
const PushRegistrationStore().setNotificationsEnabled(
|
||||||
|
settings.notificationSettings.enabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
||||||
checkerboardOffscreenLayers:
|
checkerboardOffscreenLayers:
|
||||||
@@ -335,9 +368,17 @@ class _MainState extends State<Main> {
|
|||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
listener: (context, accountState) {
|
listener: (context, accountState) {
|
||||||
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
// 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) {
|
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(
|
unawaited(
|
||||||
context.read<NextcloudCapabilitiesCubit>().load(),
|
context.read<NextcloudCapabilitiesCubit>().load(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -57,14 +56,6 @@ class AccountData {
|
|||||||
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
|
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
|
||||||
.toString();
|
.toString();
|
||||||
|
|
||||||
Future<String> getDeviceId() async => sha512
|
|
||||||
.convert(
|
|
||||||
utf8.encode(
|
|
||||||
'${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toString();
|
|
||||||
|
|
||||||
Future<void> setData(String username, String password) async {
|
Future<void> setData(String username, String password) async {
|
||||||
await _secureStorage.write(key: _usernameField, value: username);
|
await _secureStorage.write(key: _usernameField, value: username);
|
||||||
await _secureStorage.write(key: _passwordField, value: password);
|
await _secureStorage.write(key: _passwordField, value: password);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class DataCleaner {
|
|||||||
.get();
|
.get();
|
||||||
cacheData?.forEach((key, value) async {
|
cacheData?.forEach((key, value) async {
|
||||||
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
|
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
|
||||||
value['lastupdate'] as int,
|
((value['lastupdate'] as num?) ?? 0).toInt(),
|
||||||
);
|
);
|
||||||
if (DateTime.now()
|
if (DateTime.now()
|
||||||
.subtract(const Duration(days: 200))
|
.subtract(const Duration(days: 200))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../push/push_message_handler.dart';
|
import '../push/push_message_handler.dart';
|
||||||
|
import '../routing/app_routes.dart';
|
||||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../widget/debug/debug_tile.dart';
|
import '../widget/debug/debug_tile.dart';
|
||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
@@ -41,10 +42,19 @@ class NotificationController {
|
|||||||
RemoteMessage message,
|
RemoteMessage message,
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) async {
|
) async {
|
||||||
NotificationTasks.navigateToTalk(
|
final newsletterId = _extractNewsletterId(message);
|
||||||
context,
|
if (newsletterId != null) {
|
||||||
chatToken: _extractChatToken(message),
|
AppRoutes.openNewsletterById(
|
||||||
);
|
context,
|
||||||
|
id: newsletterId,
|
||||||
|
title: message.notification?.title,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
NotificationTasks.navigateToTalk(
|
||||||
|
context,
|
||||||
|
chatToken: _extractChatToken(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
NotificationTasks.updateProviders(context);
|
NotificationTasks.updateProviders(context);
|
||||||
unawaited(NotificationTasks.refreshBadge());
|
unawaited(NotificationTasks.refreshBadge());
|
||||||
|
|
||||||
@@ -66,4 +76,9 @@ class NotificationController {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _extractNewsletterId(RemoteMessage message) {
|
||||||
|
final value = message.data['newsletterId'];
|
||||||
|
return value is String && value.isNotEmpty ? value : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,15 +77,24 @@ class PushMessageHandler {
|
|||||||
String? openChatToken,
|
String? openChatToken,
|
||||||
}) async {
|
}) async {
|
||||||
final data = message.data;
|
final data = message.data;
|
||||||
|
// The device stays registered even when the user turns notifications off,
|
||||||
|
// so silent sync pushes (deletes, data refresh) keep arriving. When off we
|
||||||
|
// still process the message but skip raising a visible notification.
|
||||||
|
final notificationsEnabled = await _registrationStore.notificationsEnabled();
|
||||||
switch (classifyPush(data)) {
|
switch (classifyPush(data)) {
|
||||||
case PushKind.connect:
|
case PushKind.connect:
|
||||||
await _handleConnect(message, foreground: foreground);
|
await _handleConnect(
|
||||||
|
message,
|
||||||
|
foreground: foreground,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case PushKind.nextcloud:
|
case PushKind.nextcloud:
|
||||||
await _handleNextcloud(
|
await _handleNextcloud(
|
||||||
data,
|
data,
|
||||||
foreground: foreground,
|
foreground: foreground,
|
||||||
openChatToken: openChatToken,
|
openChatToken: openChatToken,
|
||||||
|
notificationsEnabled: notificationsEnabled,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case PushKind.unknown:
|
case PushKind.unknown:
|
||||||
@@ -96,7 +105,11 @@ class PushMessageHandler {
|
|||||||
Future<void> _handleConnect(
|
Future<void> _handleConnect(
|
||||||
RemoteMessage message, {
|
RemoteMessage message, {
|
||||||
required bool foreground,
|
required bool foreground,
|
||||||
|
required bool notificationsEnabled,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Connect pushes carry no silent side effects, so nothing to do when the
|
||||||
|
// user has notifications off.
|
||||||
|
if (!notificationsEnabled) return;
|
||||||
// On iOS the alert is delivered natively by the system; only Android needs
|
// On iOS the alert is delivered natively by the system; only Android needs
|
||||||
// to render the plaintext payload locally.
|
// to render the plaintext payload locally.
|
||||||
final data = message.data;
|
final data = message.data;
|
||||||
@@ -114,6 +127,7 @@ class PushMessageHandler {
|
|||||||
Map<String, dynamic> data, {
|
Map<String, dynamic> data, {
|
||||||
required bool foreground,
|
required bool foreground,
|
||||||
required String? openChatToken,
|
required String? openChatToken,
|
||||||
|
required bool notificationsEnabled,
|
||||||
}) async {
|
}) async {
|
||||||
final subjectBase64 = data['subject'] as String;
|
final subjectBase64 = data['subject'] as String;
|
||||||
final signatureBase64 = data['signature'] as String;
|
final signatureBase64 = data['signature'] as String;
|
||||||
@@ -153,6 +167,11 @@ class PushMessageHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notifications turned off: the push was still processed (deletes above,
|
||||||
|
// plus the foreground badge/provider refresh in NotificationController) —
|
||||||
|
// only the visible tray notification is suppressed.
|
||||||
|
if (!notificationsEnabled) return;
|
||||||
|
|
||||||
await _renderer.render(subject);
|
await _renderer.render(subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,21 +350,26 @@ class PushRegistration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers this device when push is both user-enabled and backend-capable.
|
/// Registers this device whenever the backend advertises the push capability.
|
||||||
/// Only registers when the OS notification permission is *already* granted —
|
/// Deliberately independent of the in-app notification toggle: a user who
|
||||||
/// it never triggers the OS prompt itself. Requesting the permission is the
|
/// turned notifications off stays registered so silent sync pushes keep
|
||||||
/// job of the first Talk visit (see `maybePromptTalkNotifications`), which
|
/// flowing — the display is suppressed downstream via the mirrored flag (see
|
||||||
/// keeps the prompt out of the cold-start path. Safe to call on every start —
|
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
|
||||||
/// Nextcloud dedups an unchanged registration — which also self-heals a
|
/// notification permission is *already* granted — it never triggers the OS
|
||||||
/// device whose registration was lost.
|
/// prompt itself. Requesting the permission is the job of the first Talk visit
|
||||||
static Future<void> syncSubscription({
|
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
|
||||||
required bool enabled,
|
/// cold-start path. Safe to call on every start — Nextcloud dedups an
|
||||||
required bool capable,
|
/// unchanged registration — which also self-heals a device whose registration
|
||||||
}) async {
|
/// was lost.
|
||||||
if (!(enabled && capable)) return;
|
/// Returns whether registration was actually *attempted* (all gates passed).
|
||||||
|
/// Even a partial success persists the `general` device identifier, so the
|
||||||
|
/// caller re-emits telemetry on `true` to reflect the fresh registration in
|
||||||
|
/// the same session instead of lagging until the next launch.
|
||||||
|
static Future<bool> syncSubscription({required bool capable}) async {
|
||||||
|
if (!capable) return false;
|
||||||
if (!await isOsPermissionGranted()) {
|
if (!await isOsPermissionGranted()) {
|
||||||
log('Push: OS notification permission not granted, skipping registration');
|
log('Push: OS notification permission not granted, skipping registration');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
final registration = PushRegistration();
|
final registration = PushRegistration();
|
||||||
// register() below refreshes an unchanged subscription anyway; the check
|
// register() below refreshes an unchanged subscription anyway; the check
|
||||||
@@ -373,6 +378,7 @@ class PushRegistration {
|
|||||||
log('Push: registered endpoints outdated, re-registering');
|
log('Push: registered endpoints outdated, re-registering');
|
||||||
}
|
}
|
||||||
await registration.register();
|
await registration.register();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
|
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class PushRegistrationStore {
|
|||||||
// (AccountData writes `nextcloud_app_password` group-scoped).
|
// (AccountData writes `nextcloud_app_password` group-scoped).
|
||||||
static const _usernameKey = 'nextcloud_username';
|
static const _usernameKey = 'nextcloud_username';
|
||||||
static const _baseUrlKey = 'nextcloud_base_url';
|
static const _baseUrlKey = 'nextcloud_base_url';
|
||||||
|
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
|
||||||
|
// written group-scoped so the FCM background isolate (no bloc access) and the
|
||||||
|
// iOS NSE can gate rendering. The device stays *registered* when off so silent
|
||||||
|
// sync pushes keep flowing — only the visible alert is suppressed.
|
||||||
|
static const _notificationsEnabledKey = 'push_notifications_enabled';
|
||||||
|
|
||||||
static const _perTypeKeys = [
|
static const _perTypeKeys = [
|
||||||
_deviceIdentifierKey,
|
_deviceIdentifierKey,
|
||||||
@@ -82,6 +87,19 @@ class PushRegistrationStore {
|
|||||||
await _storage.write(key: _baseUrlKey, value: baseUrl);
|
await _storage.write(key: _baseUrlKey, value: baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors the in-app notification toggle so the background isolate / iOS NSE
|
||||||
|
/// can read it without bloc access.
|
||||||
|
Future<void> setNotificationsEnabled(bool enabled) =>
|
||||||
|
_storage.write(
|
||||||
|
key: _notificationsEnabledKey,
|
||||||
|
value: enabled ? '1' : '0',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The mirrored notification toggle. Defaults to `true` when unset (fresh
|
||||||
|
/// install / pre-mirror build) so a missing mirror never silences pushes.
|
||||||
|
Future<bool> notificationsEnabled() async =>
|
||||||
|
await _storage.read(key: _notificationsEnabledKey) != '0';
|
||||||
|
|
||||||
Future<String?> deviceIdentifier(PushRegistrationType type) =>
|
Future<String?> deviceIdentifier(PushRegistrationType type) =>
|
||||||
_storage.read(key: keyFor(_deviceIdentifierKey, type));
|
_storage.read(key: keyFor(_deviceIdentifierKey, type));
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user