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*
|
||||
**/.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';
|
||||
|
||||
/// Demo fixtures for the info messages — a single friendly welcome entry so the
|
||||
@@ -6,16 +8,16 @@ class DemoMarianumMessage {
|
||||
const DemoMarianumMessage._();
|
||||
|
||||
static MarianumMessageList list() {
|
||||
final today = DateTime.now();
|
||||
final date =
|
||||
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
|
||||
final date = DateFormat.yMMMM('de').format(DateTime.now());
|
||||
|
||||
return MarianumMessageList(
|
||||
base: 'https://marianum-fulda.de',
|
||||
messages: [
|
||||
MarianumMessage(
|
||||
id: 'demo',
|
||||
name: 'Willkommen in der Marianum-App',
|
||||
date: date,
|
||||
description: 'Eine kurze Begrüßung zum Ausprobieren.',
|
||||
url: 'https://marianum-fulda.de',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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_holidays.dart';
|
||||
import 'data/demo_timetable.dart';
|
||||
import 'data/demo_users.dart';
|
||||
|
||||
/// Single source of truth for the MarianumConnect demo responses. The demo
|
||||
/// interceptor asks this for the body of any MC request. Read endpoints reuse
|
||||
@@ -32,6 +33,10 @@ class DemoMarianumConnect {
|
||||
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||
case 'holidays':
|
||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||
case 'users/search':
|
||||
return DemoUsers.search(query['q']?.toString() ?? '')
|
||||
.map((e) => e.toJson())
|
||||
.toList();
|
||||
case 'breaker':
|
||||
return DemoBreaker.none().toJson();
|
||||
case 'timetable/elements/teachers':
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
/// Raised when a CONTENT ticker page has no native ProseMirror payload yet
|
||||
/// (404 `CONTENT_UNAVAILABLE`). Carries the site-relative [webUrl] so the UI
|
||||
/// can offer to open the page in the browser instead. Not retryable — the
|
||||
/// content only appears after an admin re-saves the page.
|
||||
class TickerContentUnavailableException extends AppException {
|
||||
final String? webUrl;
|
||||
|
||||
const TickerContentUnavailableException({
|
||||
this.webUrl,
|
||||
super.technicalDetails,
|
||||
}) : super(
|
||||
userMessage:
|
||||
'Diese Seite ist in der App noch nicht verfügbar. '
|
||||
'Du kannst sie im Browser öffnen.',
|
||||
allowRetry: false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../get_poll/get_poll_state_response.dart';
|
||||
import '../talk_api.dart';
|
||||
|
||||
/// Schließt eine Umfrage endgültig — nur Ersteller oder Moderatoren.
|
||||
class ClosePoll extends TalkApi<GetPollStateResponse> {
|
||||
ClosePoll({required String token, required int pollId})
|
||||
: super('v1/poll/$token/$pollId', null);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
Uri uri,
|
||||
ApiParams? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.delete(uri, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'create_poll_params.dart';
|
||||
|
||||
/// Erstellt eine Umfrage; der Server postet die Poll-Nachricht selbst in den
|
||||
/// Chat, danach genügt ein Chat-Refresh. Nur in Gruppen-Chats erlaubt.
|
||||
class CreatePoll extends TalkApi {
|
||||
CreatePoll({required String token, required CreatePollParams params})
|
||||
: super(
|
||||
'v1/poll/$token',
|
||||
params,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
@override
|
||||
Null assemble(String raw) => null;
|
||||
|
||||
@override
|
||||
Future<http.Response>? request(
|
||||
Uri uri,
|
||||
ApiParams? body,
|
||||
Map<String, String>? headers,
|
||||
) {
|
||||
if (body is! CreatePollParams) return null;
|
||||
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../../api_params.dart';
|
||||
|
||||
part 'create_poll_params.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class CreatePollParams extends ApiParams {
|
||||
String question;
|
||||
List<String> options;
|
||||
|
||||
/// 0 = Ergebnisse öffentlich, 1 = bis zum Schließen verborgen.
|
||||
int resultMode;
|
||||
|
||||
/// Stimmen pro Teilnehmer; 0 = unbegrenzt.
|
||||
int maxVotes;
|
||||
|
||||
CreatePollParams({
|
||||
required this.question,
|
||||
required this.options,
|
||||
required this.resultMode,
|
||||
required this.maxVotes,
|
||||
});
|
||||
factory CreatePollParams.fromJson(Map<String, dynamic> json) =>
|
||||
_$CreatePollParamsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CreatePollParamsToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'create_poll_params.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
|
||||
CreatePollParams(
|
||||
question: json['question'] as String,
|
||||
options: (json['options'] as List<dynamic>)
|
||||
.map((e) => e as String)
|
||||
.toList(),
|
||||
resultMode: (json['resultMode'] as num).toInt(),
|
||||
maxVotes: (json['maxVotes'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
|
||||
<String, dynamic>{
|
||||
'question': instance.question,
|
||||
'options': instance.options,
|
||||
'resultMode': instance.resultMode,
|
||||
'maxVotes': instance.maxVotes,
|
||||
};
|
||||
@@ -1,16 +1,22 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../talk_api.dart';
|
||||
import 'create_room_params.dart';
|
||||
import 'create_room_response.dart';
|
||||
|
||||
class CreateRoom extends TalkApi {
|
||||
class CreateRoom extends TalkApi<CreateRoomResponse> {
|
||||
CreateRoomParams params;
|
||||
|
||||
CreateRoom(this.params) : super('v4/room', params);
|
||||
|
||||
@override
|
||||
Null assemble(String raw) => null;
|
||||
CreateRoomResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response>? request(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../../api_response.dart';
|
||||
|
||||
part 'create_room_response.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class CreateRoomResponse extends ApiResponse {
|
||||
final CreateRoomResponseData data;
|
||||
|
||||
CreateRoomResponse(this.data);
|
||||
|
||||
factory CreateRoomResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$CreateRoomResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class CreateRoomResponseData {
|
||||
final String token;
|
||||
|
||||
CreateRoomResponseData(this.token);
|
||||
|
||||
factory CreateRoomResponseData.fromJson(Map<String, dynamic> json) =>
|
||||
_$CreateRoomResponseDataFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CreateRoomResponseDataToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'create_room_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
|
||||
CreateRoomResponse(
|
||||
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
|
||||
)
|
||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, e as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'headers': ?instance.headers,
|
||||
'data': instance.data.toJson(),
|
||||
};
|
||||
|
||||
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => CreateRoomResponseData(json['token'] as String);
|
||||
|
||||
Map<String, dynamic> _$CreateRoomResponseDataToJson(
|
||||
CreateRoomResponseData instance,
|
||||
) => <String, dynamic>{'token': instance.token};
|
||||
@@ -4,6 +4,18 @@ import '../../../api_response.dart';
|
||||
|
||||
part 'get_poll_state_response.g.dart';
|
||||
|
||||
/// Poll-`status`-Werte der Talk-API.
|
||||
const int pollStatusOpen = 0;
|
||||
const int pollStatusClosed = 1;
|
||||
|
||||
/// Poll-`resultMode`-Werte der Talk-API.
|
||||
const int pollResultModePublic = 0;
|
||||
const int pollResultModeHidden = 1;
|
||||
|
||||
/// `participantType`-Werte (aus dem Room), die eine Umfrage schließen dürfen:
|
||||
/// Owner (1), Moderator (2) und Gast-Moderator (6).
|
||||
const Set<int> pollModeratorParticipantTypes = {1, 2, 6};
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class GetPollStateResponse extends ApiResponse {
|
||||
GetPollStateResponseObject data;
|
||||
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
|
||||
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetPollStateResponseObjectFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
|
||||
|
||||
bool get isClosed => status == pollStatusClosed;
|
||||
|
||||
bool get resultsHidden => resultMode == pollResultModeHidden;
|
||||
|
||||
/// Ergebnisse sichtbar: öffentliche Umfragen jederzeit, verborgene erst nach
|
||||
/// dem Schließen. Der Typ von `votes` taugt nicht als Signal (siehe unten).
|
||||
bool get resultsVisible => resultMode == pollResultModePublic || isClosed;
|
||||
|
||||
/// Normalisiert das dynamische `votes`-Feld zu einer Map: der Server liefert
|
||||
/// bei verborgenen Ergebnissen (und ohne Stimmen) eine leere Liste statt Map.
|
||||
Map<String, num> get voteCounts {
|
||||
final raw = votes;
|
||||
if (raw is! Map) return const {};
|
||||
final result = <String, num>{};
|
||||
raw.forEach((key, value) {
|
||||
if (key is String && value is num) result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Darf der Nutzer die (offene) Umfrage schließen: als Ersteller oder Moderator.
|
||||
bool canClose({required String selfId, required int participantType}) {
|
||||
if (isClosed) return false;
|
||||
final isCreator = actorType == 'users' && actorId == selfId;
|
||||
final isModerator = pollModeratorParticipantTypes.contains(participantType);
|
||||
return isCreator || isModerator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../get_poll/get_poll_state_response.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'vote_poll_params.dart';
|
||||
|
||||
class VotePoll extends TalkApi<GetPollStateResponse> {
|
||||
// Body als echtes JSON (nicht form-encoded wie die anderen Endpunkte): nur
|
||||
// so kommt das int-Array an; sonst liest der Server optionIds als [] und
|
||||
// löscht die eigene Stimme (Ursache des Readonly-Fallbacks, Issue #42).
|
||||
VotePoll({
|
||||
required String token,
|
||||
required int pollId,
|
||||
required VotePollParams params,
|
||||
}) : super(
|
||||
'v1/poll/$token/$pollId',
|
||||
params,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<http.Response>? request(
|
||||
Uri uri,
|
||||
ApiParams? body,
|
||||
Map<String, String>? headers,
|
||||
) {
|
||||
if (body is! VotePollParams) return null;
|
||||
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../../api_params.dart';
|
||||
|
||||
part 'vote_poll_params.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class VotePollParams extends ApiParams {
|
||||
/// Indizes der gewählten Optionen; leer = eigene Stimme zurückziehen.
|
||||
List<int> optionIds;
|
||||
|
||||
VotePollParams({required this.optionIds});
|
||||
factory VotePollParams.fromJson(Map<String, dynamic> json) =>
|
||||
_$VotePollParamsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$VotePollParamsToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'vote_poll_params.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
|
||||
VotePollParams(
|
||||
optionIds: (json['optionIds'] as List<dynamic>)
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
|
||||
<String, dynamic>{'optionIds': instance.optionIds};
|
||||
@@ -1,14 +0,0 @@
|
||||
import '../../../../api_response.dart';
|
||||
import '../../webdav_api.dart';
|
||||
import 'download_file_params.dart';
|
||||
|
||||
class DownloadFile extends WebdavApi<DownloadFileParams> {
|
||||
DownloadFileParams params;
|
||||
|
||||
DownloadFile(this.params) : super(params);
|
||||
|
||||
@override
|
||||
Future<ApiResponse> run() async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../../../api_params.dart';
|
||||
|
||||
part 'download_file_params.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class DownloadFileParams extends ApiParams {
|
||||
String webdavSourcePath;
|
||||
String localTargetPath;
|
||||
String filename;
|
||||
|
||||
DownloadFileParams(
|
||||
this.webdavSourcePath,
|
||||
this.localTargetPath,
|
||||
this.filename,
|
||||
);
|
||||
|
||||
factory DownloadFileParams.fromJson(Map<String, dynamic> json) =>
|
||||
_$DownloadFileParamsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$DownloadFileParamsToJson(this);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'download_file_params.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
DownloadFileParams _$DownloadFileParamsFromJson(Map<String, dynamic> json) =>
|
||||
DownloadFileParams(
|
||||
json['webdavSourcePath'] as String,
|
||||
json['localTargetPath'] as String,
|
||||
json['filename'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadFileParamsToJson(DownloadFileParams instance) =>
|
||||
<String, dynamic>{
|
||||
'webdavSourcePath': instance.webdavSourcePath,
|
||||
'localTargetPath': instance.localTargetPath,
|
||||
'filename': instance.filename,
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'download_file_response.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class DownloadFileResponse {
|
||||
String path;
|
||||
|
||||
DownloadFileResponse(this.path);
|
||||
|
||||
factory DownloadFileResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$DownloadFileResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$DownloadFileResponseToJson(this);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'download_file_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
DownloadFileResponse _$DownloadFileResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => DownloadFileResponse(json['path'] as String);
|
||||
|
||||
Map<String, dynamic> _$DownloadFileResponseToJson(
|
||||
DownloadFileResponse instance,
|
||||
) => <String, dynamic>{'path': instance.path};
|
||||
@@ -36,7 +36,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
// Token mitschicken statt ein eigenes 401 einzufangen.
|
||||
final pending = _pendingReLogin;
|
||||
if (pending != null) await pending;
|
||||
final token = await _tokenStorage.readToken();
|
||||
// Reading the keystore can throw while the device is locked (iOS
|
||||
// errSecInteractionNotAllowed on background requests). Degrade to an
|
||||
// unauthenticated request instead of surfacing a platform error.
|
||||
String? token;
|
||||
try {
|
||||
token = await _tokenStorage.readToken();
|
||||
} catch (_) {
|
||||
token = null;
|
||||
}
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// `first_unlock` accessibility so the token can be read during background
|
||||
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
||||
/// `-25308 errSecInteractionNotAllowed` when the device is locked.
|
||||
const IOSOptions _mcIosOptions = IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock,
|
||||
);
|
||||
|
||||
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||
@@ -11,7 +19,7 @@ class MarianumConnectTokenStorage {
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
const MarianumConnectTokenStorage([
|
||||
this._storage = const FlutterSecureStorage(),
|
||||
this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions),
|
||||
]);
|
||||
|
||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'errors/marianumconnect_error.dart';
|
||||
import 'marianumconnect_api.dart';
|
||||
import 'marianumconnect_endpoint.dart';
|
||||
|
||||
/// Shared base for MarianumConnect API queries. Owns the [dio] client (the
|
||||
/// shared authenticated singleton by default) and routes calls through [guard]
|
||||
/// so every query maps a DioException to the app's typed AppExceptions the same
|
||||
/// way instead of repeating the try/catch. Subclasses with bespoke error or
|
||||
/// lifecycle handling (own dio, silent failure, custom status mapping) may skip
|
||||
/// [guard] and still reuse [dio]/[endpoint].
|
||||
abstract class MarianumConnectQuery {
|
||||
final Dio dio;
|
||||
|
||||
MarianumConnectQuery({Dio? dio}) : dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
/// Resolves [path] against the active mobile-API base URL.
|
||||
String endpoint(String path) => MarianumConnectEndpoint.resolve(path);
|
||||
|
||||
/// Runs [body], converting any DioException into the matching AppException.
|
||||
Future<T> guard<T>(Future<T> Function() body) async {
|
||||
try {
|
||||
return await body();
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,27 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'auth_login_response.dart';
|
||||
|
||||
/// Performs the Marianum-Connect bearer login. Used both by the foreground
|
||||
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
|
||||
/// run through the shared dio instance — that one has the interceptor, which
|
||||
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
||||
class AuthLogin {
|
||||
class AuthLogin extends MarianumConnectQuery {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
final Dio _dio;
|
||||
|
||||
AuthLogin({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_dio =
|
||||
dio ??
|
||||
Dio(
|
||||
super(dio: dio ?? _buildDio());
|
||||
|
||||
static Dio _buildDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
receiveTimeout: _receiveTimeout,
|
||||
@@ -37,10 +35,9 @@ class AuthLogin {
|
||||
required String username,
|
||||
required String password,
|
||||
required String tokenName,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('auth/login'),
|
||||
}) => guard(() async {
|
||||
final response = await dio.post<Map<String, dynamic>>(
|
||||
endpoint('auth/login'),
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
@@ -54,8 +51,5 @@ class AuthLogin {
|
||||
expiresAt: payload.expiresAt,
|
||||
);
|
||||
return payload;
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||
/// — a network error still clears the local token so the user isn't stuck with
|
||||
/// an unusable session.
|
||||
class AuthLogout {
|
||||
class AuthLogout extends MarianumConnectQuery {
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
final Dio _dio;
|
||||
|
||||
AuthLogout({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_dio = dio ?? MarianumConnectApi.dio();
|
||||
super.dio,
|
||||
}) : _tokenStorage = tokenStorage;
|
||||
|
||||
Future<void> run() async {
|
||||
try {
|
||||
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
|
||||
await dio.post<void>(endpoint('auth/logout'));
|
||||
} on DioException catch (_) {
|
||||
// ignore — local clear below still happens
|
||||
} finally {
|
||||
|
||||
@@ -2,8 +2,7 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../errors/auth_exception.dart';
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Probes that the stored bearer token still maps to the given credentials.
|
||||
/// Server returns 200 only when the credentials belong to the user that the
|
||||
@@ -12,21 +11,20 @@ import '../../marianumconnect_endpoint.dart';
|
||||
///
|
||||
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||
/// and obscure a real 401 with a silent re-login.
|
||||
class AuthVerify {
|
||||
class AuthVerify extends MarianumConnectQuery {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
final Dio _dio;
|
||||
|
||||
AuthVerify({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
_dio =
|
||||
dio ??
|
||||
Dio(
|
||||
super(dio: dio ?? _buildDio());
|
||||
|
||||
static Dio _buildDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
@@ -49,14 +47,12 @@ class AuthVerify {
|
||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await _dio.post<void>(
|
||||
MarianumConnectEndpoint.resolve('auth/verify'),
|
||||
return guard(() async {
|
||||
await dio.post<void>(
|
||||
endpoint('auth/verify'),
|
||||
data: {'username': username, 'password': password},
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'get_breakers_response.dart';
|
||||
|
||||
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
|
||||
/// The endpoint is public: the bearer token is attached if present but not
|
||||
/// required, so this also works before login (e.g. to block the whole app).
|
||||
class GetBreakers {
|
||||
final Dio _dio;
|
||||
class GetBreakers extends MarianumConnectQuery {
|
||||
GetBreakers({super.dio});
|
||||
|
||||
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<GetBreakersResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('breaker'),
|
||||
);
|
||||
Future<GetBreakersResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
|
||||
return GetBreakersResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ enum BreakerArea {
|
||||
files,
|
||||
@JsonValue('NEWS')
|
||||
news,
|
||||
@JsonValue('TICKER')
|
||||
ticker,
|
||||
@JsonValue('ROOMPLAN')
|
||||
roomPlan,
|
||||
@JsonValue('GRADES')
|
||||
|
||||
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
|
||||
BreakerArea.talk: 'TALK',
|
||||
BreakerArea.files: 'FILES',
|
||||
BreakerArea.news: 'NEWS',
|
||||
BreakerArea.ticker: 'TICKER',
|
||||
BreakerArea.roomPlan: 'ROOMPLAN',
|
||||
BreakerArea.grades: 'GRADES',
|
||||
BreakerArea.holidays: 'HOLIDAYS',
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'get_capabilities_response.dart';
|
||||
|
||||
/// Fetches the current user's mobile capability flags from
|
||||
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
||||
/// so the bearer token is attached automatically.
|
||||
class GetCapabilities {
|
||||
final Dio _dio;
|
||||
class GetCapabilities extends MarianumConnectQuery {
|
||||
GetCapabilities({super.dio});
|
||||
|
||||
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<CapabilitiesResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('me/capabilities'),
|
||||
Future<CapabilitiesResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('me/capabilities'),
|
||||
);
|
||||
return CapabilitiesResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import '../../models/mc_holiday.dart';
|
||||
|
||||
class GetHolidays {
|
||||
final Dio _dio;
|
||||
class GetHolidays extends MarianumConnectQuery {
|
||||
GetHolidays({super.dio});
|
||||
|
||||
GetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<List<McHoliday>> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('holidays'),
|
||||
);
|
||||
Future<List<McHoliday>> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
|
||||
return response.data!
|
||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} 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 '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
||||
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
||||
/// device-identifier signature, stores the routing metadata and starts
|
||||
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
||||
class PushDeviceRegister {
|
||||
final Dio _dio;
|
||||
|
||||
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
class PushDeviceRegister extends MarianumConnectQuery {
|
||||
PushDeviceRegister({super.dio});
|
||||
|
||||
Future<void> run({
|
||||
required String deviceIdentifier,
|
||||
@@ -21,10 +15,9 @@ class PushDeviceRegister {
|
||||
required String platform,
|
||||
required String registrationType,
|
||||
String? appVersion,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.put<void>(
|
||||
MarianumConnectEndpoint.resolve('me/push-device'),
|
||||
}) => guard(() async {
|
||||
await dio.put<void>(
|
||||
endpoint('me/push-device'),
|
||||
data: {
|
||||
'deviceIdentifier': deviceIdentifier,
|
||||
'deviceIdentifierSignature': deviceIdentifierSignature,
|
||||
@@ -37,8 +30,5 @@ class PushDeviceRegister {
|
||||
'appVersion': ?appVersion,
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Triggers a test push to all of the current user's registered devices via
|
||||
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
|
||||
/// backend dispatched to (0 when none are registered).
|
||||
class PushDeviceTest {
|
||||
final Dio _dio;
|
||||
class PushDeviceTest extends MarianumConnectQuery {
|
||||
PushDeviceTest({super.dio});
|
||||
|
||||
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<int> run() async {
|
||||
try {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('me/push-device/test'),
|
||||
Future<int> run() => guard(() async {
|
||||
final response = await dio.post<Map<String, dynamic>>(
|
||||
endpoint('me/push-device/test'),
|
||||
);
|
||||
return (response.data?['devices'] as num?)?.toInt() ?? 0;
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Removes this device's push subscription from MarianumConnect via
|
||||
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
|
||||
/// (204 even when the row is already gone).
|
||||
class PushDeviceUnregister {
|
||||
final Dio _dio;
|
||||
class PushDeviceUnregister extends MarianumConnectQuery {
|
||||
PushDeviceUnregister({super.dio});
|
||||
|
||||
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<void> run({required String deviceIdentifier}) async {
|
||||
try {
|
||||
await _dio.delete<void>(
|
||||
MarianumConnectEndpoint.resolve('me/push-device'),
|
||||
Future<void> run({required String deviceIdentifier}) => guard(() async {
|
||||
await dio.delete<void>(
|
||||
endpoint('me/push-device'),
|
||||
queryParameters: {'deviceIdentifier': deviceIdentifier},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Sends a single client-side error report to MarianumConnect
|
||||
/// (`POST client-errors`). The endpoint is public, so reports that happen
|
||||
/// before login are still captured; when a bearer token is present the shared
|
||||
/// dio interceptor attaches it and the server attributes the report to that user.
|
||||
class ReportClientError {
|
||||
final Dio _dio;
|
||||
|
||||
ReportClientError({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
class ReportClientError extends MarianumConnectQuery {
|
||||
ReportClientError({super.dio});
|
||||
|
||||
Future<void> run({
|
||||
required String errorType,
|
||||
@@ -21,10 +15,9 @@ class ReportClientError {
|
||||
String? platform,
|
||||
String? appVersion,
|
||||
String? deviceModel,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.post<void>(
|
||||
MarianumConnectEndpoint.resolve('client-errors'),
|
||||
}) => guard(() async {
|
||||
await dio.post<void>(
|
||||
endpoint('client-errors'),
|
||||
data: {
|
||||
'errorType': errorType,
|
||||
'message': ?message,
|
||||
@@ -35,8 +28,5 @@ class ReportClientError {
|
||||
'deviceModel': ?deviceModel,
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,32 +3,26 @@ import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
||||
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
|
||||
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
|
||||
/// the bearer token identifies them.
|
||||
class SubmitFeedback {
|
||||
final Dio _dio;
|
||||
|
||||
SubmitFeedback({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
class SubmitFeedback extends MarianumConnectQuery {
|
||||
SubmitFeedback({super.dio});
|
||||
|
||||
Future<void> run({
|
||||
required String message,
|
||||
Uint8List? screenshot,
|
||||
String screenshotContentType = 'image/png',
|
||||
}) async {
|
||||
try {
|
||||
}) => guard(() async {
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
|
||||
await _dio.post<void>(
|
||||
MarianumConnectEndpoint.resolve('me/feedback'),
|
||||
await dio.post<void>(
|
||||
endpoint('me/feedback'),
|
||||
data: {
|
||||
'message': message,
|
||||
'screenshot': ?screenshotBase64,
|
||||
@@ -39,10 +33,7 @@ class SubmitFeedback {
|
||||
'deviceModel': await _deviceModel(),
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static String? _platform() {
|
||||
if (Platform.isAndroid) return 'android';
|
||||
|
||||
@@ -3,33 +3,34 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../../../push/push_registration_store.dart';
|
||||
import '../../../../push/push_registration_type.dart';
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'telemetry_device_id.dart';
|
||||
|
||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one
|
||||
/// upsert per app start carrying the stable install id, platform, app version
|
||||
/// and device info. Bearer-authenticated via the shared dio interceptor.
|
||||
/// Replaces the legacy mhsl.eu `server/userIndex/update` call.
|
||||
class TelemetryHeartbeat {
|
||||
final Dio _dio;
|
||||
|
||||
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||
/// upserts the stable install id, platform, app version and device info. Sent
|
||||
/// once on app start and again once push registration completes that session
|
||||
/// (so a fresh registration isn't under-reported until the next launch).
|
||||
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||
/// mhsl.eu `server/userIndex/update` call.
|
||||
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||
TelemetryHeartbeat({super.dio});
|
||||
|
||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
||||
/// send never disrupts app start. Used from the app shell's initState.
|
||||
static void report() {
|
||||
unawaited(TelemetryHeartbeat().send().catchError((Object _) {}));
|
||||
/// send never disrupts app start. Used from the app shell's initState and
|
||||
/// re-emitted once push registration completes (see `_MainState._syncPush`).
|
||||
static void report({required bool notificationsEnabled}) {
|
||||
unawaited(
|
||||
TelemetryHeartbeat()
|
||||
.send(notificationsEnabled: notificationsEnabled)
|
||||
.catchError((Object _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> send() async {
|
||||
try {
|
||||
Future<void> send({required bool notificationsEnabled}) => guard(() async {
|
||||
final info = DeviceInfoPlugin();
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
||||
@@ -54,11 +55,16 @@ class TelemetryHeartbeat {
|
||||
raw = appleInfo.data;
|
||||
}
|
||||
|
||||
await _dio.post<void>(
|
||||
MarianumConnectEndpoint.resolve('me/telemetry'),
|
||||
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),
|
||||
@@ -67,8 +73,5 @@ class TelemetryHeartbeat {
|
||||
'deviceInfo': jsonEncode(raw),
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_classes_response.dart';
|
||||
|
||||
class TimetableGetClasses {
|
||||
final Dio _dio;
|
||||
class TimetableGetClasses extends MarianumConnectQuery {
|
||||
TimetableGetClasses({super.dio});
|
||||
|
||||
TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetClassesResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/elements/classes'),
|
||||
Future<TimetableGetClassesResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/elements/classes'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetClassesResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-17
@@ -1,35 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import '../timetable_get_week/timetable_get_week_response.dart';
|
||||
import 'timetable_element_type.dart';
|
||||
|
||||
/// Fetches a foreign element's weekly timetable from
|
||||
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
|
||||
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
|
||||
class TimetableGetElementWeek {
|
||||
final Dio _dio;
|
||||
|
||||
TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
class TimetableGetElementWeek extends MarianumConnectQuery {
|
||||
TimetableGetElementWeek({super.dio});
|
||||
|
||||
Future<TimetableGetWeekResponse> run({
|
||||
required TimetableElementType type,
|
||||
required int id,
|
||||
required DateTime from,
|
||||
required DateTime until,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'),
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/${type.pathSegment}/$id'),
|
||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||
);
|
||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
String _format(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_holidays_response.dart';
|
||||
|
||||
class TimetableGetHolidays {
|
||||
final Dio _dio;
|
||||
class TimetableGetHolidays extends MarianumConnectQuery {
|
||||
TimetableGetHolidays({super.dio});
|
||||
|
||||
TimetableGetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetHolidaysResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/holidays'),
|
||||
Future<TimetableGetHolidaysResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/holidays'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetHolidaysResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_rooms_response.dart';
|
||||
|
||||
class TimetableGetRooms {
|
||||
final Dio _dio;
|
||||
class TimetableGetRooms extends MarianumConnectQuery {
|
||||
TimetableGetRooms({super.dio});
|
||||
|
||||
TimetableGetRooms({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetRoomsResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/rooms'),
|
||||
);
|
||||
Future<TimetableGetRoomsResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
|
||||
final list = response.data!
|
||||
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetRoomsResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-17
@@ -1,23 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_schoolyear_response.dart';
|
||||
|
||||
class TimetableGetSchoolyear {
|
||||
final Dio _dio;
|
||||
class TimetableGetSchoolyear extends MarianumConnectQuery {
|
||||
TimetableGetSchoolyear({super.dio});
|
||||
|
||||
TimetableGetSchoolyear({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetSchoolyearResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/schoolyear'),
|
||||
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/schoolyear'),
|
||||
);
|
||||
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_students_response.dart';
|
||||
|
||||
class TimetableGetStudents {
|
||||
final Dio _dio;
|
||||
class TimetableGetStudents extends MarianumConnectQuery {
|
||||
TimetableGetStudents({super.dio});
|
||||
|
||||
TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetStudentsResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/elements/students'),
|
||||
Future<TimetableGetStudentsResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/elements/students'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetStudentsResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_subjects_response.dart';
|
||||
|
||||
class TimetableGetSubjects {
|
||||
final Dio _dio;
|
||||
class TimetableGetSubjects extends MarianumConnectQuery {
|
||||
TimetableGetSubjects({super.dio});
|
||||
|
||||
TimetableGetSubjects({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetSubjectsResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/subjects'),
|
||||
Future<TimetableGetSubjectsResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/subjects'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetSubjectsResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+5
@@ -10,10 +10,15 @@ class McSubject {
|
||||
final String shortName;
|
||||
final String longName;
|
||||
|
||||
/// Persönliche Fach-Farbe (Palette-Name aus [SubjectColor]), serverseitig pro
|
||||
/// Nutzer gepflegt. `null`, wenn für dieses Fach keine Farbe gesetzt ist.
|
||||
final String? color;
|
||||
|
||||
McSubject({
|
||||
required this.id,
|
||||
required this.shortName,
|
||||
required this.longName,
|
||||
this.color,
|
||||
});
|
||||
|
||||
factory McSubject.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
+2
@@ -10,12 +10,14 @@ McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
|
||||
id: (json['id'] as num).toInt(),
|
||||
shortName: json['shortName'] as String,
|
||||
longName: json['longName'] as String,
|
||||
color: json['color'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'shortName': instance.shortName,
|
||||
'longName': instance.longName,
|
||||
'color': instance.color,
|
||||
};
|
||||
|
||||
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_teachers_response.dart';
|
||||
|
||||
class TimetableGetTeachers {
|
||||
final Dio _dio;
|
||||
class TimetableGetTeachers extends MarianumConnectQuery {
|
||||
TimetableGetTeachers({super.dio});
|
||||
|
||||
TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetTeachersResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/elements/teachers'),
|
||||
Future<TimetableGetTeachersResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/elements/teachers'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map(
|
||||
(e) =>
|
||||
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetTeachersResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_timegrid_response.dart';
|
||||
|
||||
class TimetableGetTimegrid {
|
||||
final Dio _dio;
|
||||
class TimetableGetTimegrid extends MarianumConnectQuery {
|
||||
TimetableGetTimegrid({super.dio});
|
||||
|
||||
TimetableGetTimegrid({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
|
||||
Future<TimetableGetTimegridResponse> run() async {
|
||||
try {
|
||||
final response = await _dio.get<List<dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/timegrid'),
|
||||
Future<TimetableGetTimegridResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/timegrid'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetTimegridResponse(result: list);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'timetable_get_week_response.dart';
|
||||
|
||||
class TimetableGetWeek {
|
||||
final Dio _dio;
|
||||
|
||||
TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||
class TimetableGetWeek extends MarianumConnectQuery {
|
||||
TimetableGetWeek({super.dio});
|
||||
|
||||
Future<TimetableGetWeekResponse> run({
|
||||
required DateTime from,
|
||||
required DateTime until,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
MarianumConnectEndpoint.resolve('timetable/me'),
|
||||
queryParameters: {
|
||||
'from': _format(from),
|
||||
'until': _format(until),
|
||||
},
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/me'),
|
||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||
);
|
||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
String _format(DateTime d) =>
|
||||
'${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 '../mhsl_api.dart';
|
||||
|
||||
part 'custom_timetable_event.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -9,15 +7,23 @@ class CustomTimetableEvent {
|
||||
String id;
|
||||
String title;
|
||||
String description;
|
||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
||||
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||
DateTime startDate;
|
||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
||||
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||
DateTime endDate;
|
||||
String? color;
|
||||
String rrule;
|
||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
||||
|
||||
/// When true, occurrences of a recurring event that fall inside a school
|
||||
/// holiday are hidden in the calendar. Purely client-side: the backend only
|
||||
/// stores the flag, the exclusion is applied at render time against the
|
||||
/// holiday list. Ignored for non-recurring events (empty [rrule]).
|
||||
@JsonKey(defaultValue: false)
|
||||
bool skipHolidays;
|
||||
|
||||
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||
DateTime createdAt;
|
||||
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
|
||||
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
|
||||
DateTime updatedAt;
|
||||
|
||||
CustomTimetableEvent({
|
||||
@@ -28,6 +34,7 @@ class CustomTimetableEvent {
|
||||
required this.endDate,
|
||||
required this.color,
|
||||
required this.rrule,
|
||||
this.skipHolidays = false,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -35,4 +42,13 @@ class CustomTimetableEvent {
|
||||
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$CustomTimetableEventFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CustomTimetableEventToJson(this);
|
||||
|
||||
// Marianum-Connect serializes LocalDateTime as ISO-8601 (`yyyy-MM-ddTHH:mm:ss`)
|
||||
// and its GSON parser rejects anything else. DateTime.parse still reads the
|
||||
// old MHSL `yyyy-MM-dd HH:mm:ss` shape too, so events fetched during the
|
||||
// one-time migration deserialize unchanged.
|
||||
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
|
||||
static String _dateToJson(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'
|
||||
'T${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}:${d.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ CustomTimetableEvent _$CustomTimetableEventFromJson(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
startDate: MhslApi.dateTimeFromJson(json['startDate'] as String),
|
||||
endDate: MhslApi.dateTimeFromJson(json['endDate'] as String),
|
||||
startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String),
|
||||
endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String),
|
||||
color: json['color'] as String?,
|
||||
rrule: json['rrule'] as String,
|
||||
createdAt: MhslApi.dateTimeFromJson(json['createdAt'] as String),
|
||||
updatedAt: MhslApi.dateTimeFromJson(json['updatedAt'] as String),
|
||||
skipHolidays: json['skipHolidays'] as bool? ?? false,
|
||||
createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String),
|
||||
updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CustomTimetableEventToJson(
|
||||
@@ -26,10 +27,11 @@ Map<String, dynamic> _$CustomTimetableEventToJson(
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'startDate': MhslApi.dateTimeToJson(instance.startDate),
|
||||
'endDate': MhslApi.dateTimeToJson(instance.endDate),
|
||||
'startDate': CustomTimetableEvent._dateToJson(instance.startDate),
|
||||
'endDate': CustomTimetableEvent._dateToJson(instance.endDate),
|
||||
'color': instance.color,
|
||||
'rrule': instance.rrule,
|
||||
'createdAt': MhslApi.dateTimeToJson(instance.createdAt),
|
||||
'updatedAt': MhslApi.dateTimeToJson(instance.updatedAt),
|
||||
'skipHolidays': instance.skipHolidays,
|
||||
'createdAt': CustomTimetableEvent._dateToJson(instance.createdAt),
|
||||
'updatedAt': CustomTimetableEvent._dateToJson(instance.updatedAt),
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import '../../../request_cache.dart';
|
||||
import 'get_custom_timetable_event.dart';
|
||||
import 'get_custom_timetable_event_params.dart';
|
||||
import 'get_custom_timetable_event_response.dart';
|
||||
|
||||
class GetCustomTimetableEventCache
|
||||
extends SimpleCache<GetCustomTimetableEventResponse> {
|
||||
GetCustomTimetableEventCache(
|
||||
GetCustomTimetableEventParams params, {
|
||||
super.onUpdate,
|
||||
super.onError,
|
||||
super.renew,
|
||||
}) : super(
|
||||
cacheTime: RequestCache.cacheMinute,
|
||||
loader: () => GetCustomTimetableEvent(params).run(),
|
||||
fromJson: GetCustomTimetableEventResponse.fromJson,
|
||||
) {
|
||||
start('customTimetableEvents');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../mhsl_api.dart';
|
||||
import 'update_custom_timetable_event_params.dart';
|
||||
|
||||
class UpdateCustomTimetableEvent extends MhslApi<void> {
|
||||
UpdateCustomTimetableEventParams params;
|
||||
|
||||
UpdateCustomTimetableEvent(this.params)
|
||||
: super('server/timetable/customEvents');
|
||||
|
||||
@override
|
||||
void assemble(String raw) {}
|
||||
|
||||
@override
|
||||
Future<Response>? request(Uri uri) =>
|
||||
http.patch(uri, body: jsonEncode(params.toJson()));
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../custom_timetable_event.dart';
|
||||
|
||||
part 'update_custom_timetable_event_params.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class UpdateCustomTimetableEventParams {
|
||||
String id;
|
||||
CustomTimetableEvent event;
|
||||
|
||||
UpdateCustomTimetableEventParams(this.id, this.event);
|
||||
|
||||
factory UpdateCustomTimetableEventParams.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$UpdateCustomTimetableEventParamsFromJson(json);
|
||||
Map<String, dynamic> toJson() =>
|
||||
_$UpdateCustomTimetableEventParamsToJson(this);
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'update_custom_timetable_event_params.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
UpdateCustomTimetableEventParams _$UpdateCustomTimetableEventParamsFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => UpdateCustomTimetableEventParams(
|
||||
json['id'] as String,
|
||||
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UpdateCustomTimetableEventParamsToJson(
|
||||
UpdateCustomTimetableEventParams instance,
|
||||
) => <String, dynamic>{'id': instance.id, 'event': instance.event.toJson()};
|
||||
+16
-6
@@ -93,6 +93,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
NotificationTasks.navigateToTalk(context, chatToken: token);
|
||||
}
|
||||
|
||||
void _onNewsletterTapPending() {
|
||||
final id = PushTapRouter.pendingNewsletterId.value;
|
||||
if (id == null || !mounted) return;
|
||||
PushTapRouter.pendingNewsletterId.value = null;
|
||||
AppRoutes.openNewsletterById(context, id: id);
|
||||
}
|
||||
|
||||
Future<void> _handlePendingWidgetNavigation() async {
|
||||
final pending = await WidgetNavigation.consumePendingTimetableTap();
|
||||
if (!pending || !mounted) return;
|
||||
@@ -171,26 +178,28 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
TelemetryHeartbeat.report();
|
||||
TelemetryHeartbeat.report(
|
||||
notificationsEnabled:
|
||||
context.read<SettingsCubit>().val().notificationSettings.enabled,
|
||||
);
|
||||
|
||||
// A refreshed FCM token invalidates the existing push subscription — the
|
||||
// NC device identifier stays stable, so we simply re-register (NC first,
|
||||
// then the proxy). Debounced so a burst of refreshes triggers one call.
|
||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
|
||||
_,
|
||||
) {
|
||||
// Not gated on the notification toggle: registration is kept alive even
|
||||
// when notifications are off so silent sync pushes keep flowing.
|
||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((_) {
|
||||
Debouncer.debounce(
|
||||
'pushTokenRefresh',
|
||||
const Duration(seconds: 3),
|
||||
() => unawaited(PushRegistration().onTokenRefresh()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Android renders pushes locally, so a tap arrives via the local
|
||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
||||
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
||||
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
|
||||
|
||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||
if (!mounted) return;
|
||||
@@ -221,6 +230,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
_onMessageOpenedAppSub?.cancel();
|
||||
_fcmTokenRefreshSub?.cancel();
|
||||
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
||||
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
|
||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||
ShareIntentListener.instance.detach();
|
||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||
|
||||
@@ -64,6 +64,9 @@ extension DateTimeFormatting on DateTime {
|
||||
|
||||
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
||||
|
||||
/// Compact `yyyyMMdd` key, e.g. to identify a week-start in timetable caches.
|
||||
String weekKey() => Jiffy.parseFromDateTime(this).format(pattern: 'yyyyMMdd');
|
||||
|
||||
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
||||
|
||||
String formatDateRelativeShort({DateTime? now}) {
|
||||
|
||||
@@ -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/queries/get_breakers/get_breakers_response.dart';
|
||||
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
|
||||
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||
import 'app.dart';
|
||||
import 'background/widget_background_task.dart';
|
||||
import 'firebase_options.dart';
|
||||
@@ -29,6 +30,7 @@ import 'model/account_data.dart';
|
||||
import 'notification/notification_service.dart';
|
||||
import 'push/push_message_handler.dart';
|
||||
import 'push/push_registration.dart';
|
||||
import 'push/push_registration_store.dart';
|
||||
import 'push/push_renderer.dart';
|
||||
import 'routing/app_routes.dart';
|
||||
import 'share_intent/share_intent_listener.dart';
|
||||
@@ -49,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
|
||||
import 'view/login/login.dart';
|
||||
import 'view/login/post_login_splash.dart';
|
||||
import 'widget/app_progress_indicator.dart';
|
||||
import 'widget/avatar_disk_cache.dart';
|
||||
import 'widget/breaker/breaker.dart';
|
||||
import 'widget/debug/cache_view.dart';
|
||||
import 'widget/downloads/download_tray.dart';
|
||||
@@ -119,10 +122,19 @@ Future<void> main() async {
|
||||
),
|
||||
);
|
||||
|
||||
// Diagnostic log only. getToken() can fail transiently during cold start
|
||||
// (Android: "IOException: FCM Registration failed!" from Play Services,
|
||||
// iOS: apns-token-not-set until APNS registration completes), so tolerate
|
||||
// the failure instead of letting it surface as an uncaught async error that
|
||||
// gets reported to the telemetry backend as noise.
|
||||
unawaited(
|
||||
FirebaseMessaging.instance.getToken().then(
|
||||
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
|
||||
),
|
||||
FirebaseMessaging.instance
|
||||
.getToken()
|
||||
.then(
|
||||
(token) =>
|
||||
log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
|
||||
)
|
||||
.onError((e, _) => log('Firebase token unavailable: $e')),
|
||||
);
|
||||
|
||||
// Warm up the Nextcloud root listing in the background while the user is
|
||||
@@ -139,6 +151,11 @@ Future<void> main() async {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the avatar cache directory ahead of the first avatar render so the
|
||||
// synchronous disk read hits and cold-start avatars appear without a blank
|
||||
// placeholder flash.
|
||||
AvatarDiskCache.instance.warmUp();
|
||||
|
||||
if (kReleaseMode) {
|
||||
ErrorWidget.builder = (error) => Material(
|
||||
color: Colors.white,
|
||||
@@ -251,14 +268,23 @@ class _MainState extends State<Main> {
|
||||
unawaited(ListFilesCache.prefetchRootListing());
|
||||
}
|
||||
|
||||
/// Registers/self-heals the push subscription when push is user-enabled and
|
||||
/// the backend advertises the capability. Fire-and-forget.
|
||||
/// Registers/self-heals the push subscription whenever the backend advertises
|
||||
/// the capability — independent of the notification toggle, so a user with
|
||||
/// notifications off stays registered for silent sync pushes. Fire-and-forget.
|
||||
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
|
||||
final enabled = settings.val().notificationSettings.enabled;
|
||||
unawaited(
|
||||
PushRegistration.syncSubscription(
|
||||
enabled: settings.val().notificationSettings.enabled,
|
||||
capable: capabilities.canReceivePushNotifications,
|
||||
),
|
||||
).then((registered) {
|
||||
// The app-start heartbeat runs before this async registration
|
||||
// finishes, so it reports the pre-registration state. Re-emit once
|
||||
// the identifier is persisted so the new push status shows up this
|
||||
// session instead of only after the next launch.
|
||||
if (registered) {
|
||||
TelemetryHeartbeat.report(notificationsEnabled: enabled);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,6 +316,13 @@ class _MainState extends State<Main> {
|
||||
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
|
||||
MarianumConnectEndpoint.update(mcBaseUrl);
|
||||
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
|
||||
// Mirror the notification toggle into group-scoped storage so the FCM
|
||||
// background isolate and the iOS NSE can suppress rendering when off.
|
||||
unawaited(
|
||||
const PushRegistrationStore().setNotificationsEnabled(
|
||||
settings.notificationSettings.enabled,
|
||||
),
|
||||
);
|
||||
return MaterialApp(
|
||||
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
||||
checkerboardOffscreenLayers:
|
||||
@@ -335,9 +368,17 @@ class _MainState extends State<Main> {
|
||||
previous.status != current.status,
|
||||
listener: (context, accountState) {
|
||||
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
||||
// for the newly authenticated user.
|
||||
// for the newly authenticated user, then register push right
|
||||
// away instead of deferring it to the next app start.
|
||||
if (accountState.status == AccountStatus.loggedIn) {
|
||||
unawaited(context.read<CapabilitiesCubit>().load());
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
unawaited(
|
||||
capabilitiesCubit.load().then((_) {
|
||||
if (!mounted) return;
|
||||
_syncPush(settingsCubit, capabilitiesCubit);
|
||||
}),
|
||||
);
|
||||
unawaited(
|
||||
context.read<NextcloudCapabilitiesCubit>().load(),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -57,14 +56,6 @@ class AccountData {
|
||||
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
|
||||
.toString();
|
||||
|
||||
Future<String> getDeviceId() async => sha512
|
||||
.convert(
|
||||
utf8.encode(
|
||||
'${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}',
|
||||
),
|
||||
)
|
||||
.toString();
|
||||
|
||||
Future<void> setData(String username, String password) async {
|
||||
await _secureStorage.write(key: _usernameField, value: username);
|
||||
await _secureStorage.write(key: _passwordField, value: password);
|
||||
|
||||
@@ -9,7 +9,7 @@ class DataCleaner {
|
||||
.get();
|
||||
cacheData?.forEach((key, value) async {
|
||||
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
|
||||
value['lastupdate'] as int,
|
||||
((value['lastupdate'] as num?) ?? 0).toInt(),
|
||||
);
|
||||
if (DateTime.now()
|
||||
.subtract(const Duration(days: 200))
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../push/push_message_handler.dart';
|
||||
import '../routing/app_routes.dart';
|
||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../widget/debug/debug_tile.dart';
|
||||
import '../widget/debug/json_viewer.dart';
|
||||
@@ -41,10 +42,19 @@ class NotificationController {
|
||||
RemoteMessage message,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final newsletterId = _extractNewsletterId(message);
|
||||
if (newsletterId != null) {
|
||||
AppRoutes.openNewsletterById(
|
||||
context,
|
||||
id: newsletterId,
|
||||
title: message.notification?.title,
|
||||
);
|
||||
} else {
|
||||
NotificationTasks.navigateToTalk(
|
||||
context,
|
||||
chatToken: _extractChatToken(message),
|
||||
);
|
||||
}
|
||||
NotificationTasks.updateProviders(context);
|
||||
unawaited(NotificationTasks.refreshBadge());
|
||||
|
||||
@@ -66,4 +76,9 @@ class NotificationController {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _extractNewsletterId(RemoteMessage message) {
|
||||
final value = message.data['newsletterId'];
|
||||
return value is String && value.isNotEmpty ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,15 +77,24 @@ class PushMessageHandler {
|
||||
String? openChatToken,
|
||||
}) async {
|
||||
final data = message.data;
|
||||
// The device stays registered even when the user turns notifications off,
|
||||
// so silent sync pushes (deletes, data refresh) keep arriving. When off we
|
||||
// still process the message but skip raising a visible notification.
|
||||
final notificationsEnabled = await _registrationStore.notificationsEnabled();
|
||||
switch (classifyPush(data)) {
|
||||
case PushKind.connect:
|
||||
await _handleConnect(message, foreground: foreground);
|
||||
await _handleConnect(
|
||||
message,
|
||||
foreground: foreground,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
break;
|
||||
case PushKind.nextcloud:
|
||||
await _handleNextcloud(
|
||||
data,
|
||||
foreground: foreground,
|
||||
openChatToken: openChatToken,
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
break;
|
||||
case PushKind.unknown:
|
||||
@@ -96,7 +105,11 @@ class PushMessageHandler {
|
||||
Future<void> _handleConnect(
|
||||
RemoteMessage message, {
|
||||
required bool foreground,
|
||||
required bool notificationsEnabled,
|
||||
}) async {
|
||||
// Connect pushes carry no silent side effects, so nothing to do when the
|
||||
// user has notifications off.
|
||||
if (!notificationsEnabled) return;
|
||||
// On iOS the alert is delivered natively by the system; only Android needs
|
||||
// to render the plaintext payload locally.
|
||||
final data = message.data;
|
||||
@@ -114,6 +127,7 @@ class PushMessageHandler {
|
||||
Map<String, dynamic> data, {
|
||||
required bool foreground,
|
||||
required String? openChatToken,
|
||||
required bool notificationsEnabled,
|
||||
}) async {
|
||||
final subjectBase64 = data['subject'] as String;
|
||||
final signatureBase64 = data['signature'] as String;
|
||||
@@ -153,6 +167,11 @@ class PushMessageHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notifications turned off: the push was still processed (deletes above,
|
||||
// plus the foreground badge/provider refresh in NotificationController) —
|
||||
// only the visible tray notification is suppressed.
|
||||
if (!notificationsEnabled) return;
|
||||
|
||||
await _renderer.render(subject);
|
||||
}
|
||||
|
||||
|
||||
@@ -350,21 +350,26 @@ class PushRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this device when push is both user-enabled and backend-capable.
|
||||
/// Only registers when the OS notification permission is *already* granted —
|
||||
/// it never triggers the OS prompt itself. Requesting the permission is the
|
||||
/// job of the first Talk visit (see `maybePromptTalkNotifications`), which
|
||||
/// keeps the prompt out of the cold-start path. Safe to call on every start —
|
||||
/// Nextcloud dedups an unchanged registration — which also self-heals a
|
||||
/// device whose registration was lost.
|
||||
static Future<void> syncSubscription({
|
||||
required bool enabled,
|
||||
required bool capable,
|
||||
}) async {
|
||||
if (!(enabled && capable)) return;
|
||||
/// Registers this device whenever the backend advertises the push capability.
|
||||
/// Deliberately independent of the in-app notification toggle: a user who
|
||||
/// turned notifications off stays registered so silent sync pushes keep
|
||||
/// flowing — the display is suppressed downstream via the mirrored flag (see
|
||||
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
|
||||
/// notification permission is *already* granted — it never triggers the OS
|
||||
/// prompt itself. Requesting the permission is the job of the first Talk visit
|
||||
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
|
||||
/// cold-start path. Safe to call on every start — Nextcloud dedups an
|
||||
/// unchanged registration — which also self-heals a device whose registration
|
||||
/// was lost.
|
||||
/// Returns whether registration was actually *attempted* (all gates passed).
|
||||
/// Even a partial success persists the `general` device identifier, so the
|
||||
/// caller re-emits telemetry on `true` to reflect the fresh registration in
|
||||
/// the same session instead of lagging until the next launch.
|
||||
static Future<bool> syncSubscription({required bool capable}) async {
|
||||
if (!capable) return false;
|
||||
if (!await isOsPermissionGranted()) {
|
||||
log('Push: OS notification permission not granted, skipping registration');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
final registration = PushRegistration();
|
||||
// register() below refreshes an unchanged subscription anyway; the check
|
||||
@@ -373,6 +378,7 @@ class PushRegistration {
|
||||
log('Push: registered endpoints outdated, re-registering');
|
||||
}
|
||||
await registration.register();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
|
||||
|
||||
@@ -27,6 +27,11 @@ class PushRegistrationStore {
|
||||
// (AccountData writes `nextcloud_app_password` group-scoped).
|
||||
static const _usernameKey = 'nextcloud_username';
|
||||
static const _baseUrlKey = 'nextcloud_base_url';
|
||||
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
|
||||
// written group-scoped so the FCM background isolate (no bloc access) and the
|
||||
// iOS NSE can gate rendering. The device stays *registered* when off so silent
|
||||
// sync pushes keep flowing — only the visible alert is suppressed.
|
||||
static const _notificationsEnabledKey = 'push_notifications_enabled';
|
||||
|
||||
static const _perTypeKeys = [
|
||||
_deviceIdentifierKey,
|
||||
@@ -82,6 +87,19 @@ class PushRegistrationStore {
|
||||
await _storage.write(key: _baseUrlKey, value: baseUrl);
|
||||
}
|
||||
|
||||
/// Mirrors the in-app notification toggle so the background isolate / iOS NSE
|
||||
/// can read it without bloc access.
|
||||
Future<void> setNotificationsEnabled(bool enabled) =>
|
||||
_storage.write(
|
||||
key: _notificationsEnabledKey,
|
||||
value: enabled ? '1' : '0',
|
||||
);
|
||||
|
||||
/// The mirrored notification toggle. Defaults to `true` when unset (fresh
|
||||
/// install / pre-mirror build) so a missing mirror never silences pushes.
|
||||
Future<bool> notificationsEnabled() async =>
|
||||
await _storage.read(key: _notificationsEnabledKey) != '0';
|
||||
|
||||
Future<String?> deviceIdentifier(PushRegistrationType type) =>
|
||||
_storage.read(key: keyFor(_deviceIdentifierKey, type));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user