6 Commits
460 changed files with 15845 additions and 8104 deletions

No files matched your search

-6
View File
@@ -362,9 +362,3 @@ android/fastlane/README.md.bak
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep) # Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet. # bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png android/fastlane/metadata/android/**/images/**/*.png
# Fastlane (App-Store-Upload)
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/app-store-connect-api-key.json
ios/fastlane/screenshots/**/*.jpg
+13 -5
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
## Stack ## Stack
@@ -17,7 +17,9 @@ Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. übe
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index ├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine ├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens ├── view/ Screens
@@ -52,10 +54,16 @@ lib/
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). **Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert. **Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
## Build / Run ## Build / Run
```bash ```bash
@@ -70,7 +78,7 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push | | MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
@@ -78,4 +86,4 @@ flutter test # Tests (siehe test
## Tests ## Tests
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
+17
View File
@@ -41,6 +41,23 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
</intent-filter> </intent-filter>
<!-- Guardian login mail link (App Link). Verified via
/.well-known/assetlinks.json on both hosts. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="connect.marianum-fulda.de" />
<data android:host="connect-beta.marianum-fulda.de" />
<data android:path="/app/guardian-login" />
</intent-filter>
<!-- The app routes links itself (GuardianLinkListener); Flutter's
built-in handling would push the path as a named route onto a
MaterialApp that only has `home`. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
-1
View File
@@ -4,5 +4,4 @@ targets:
json_serializable: json_serializable:
options: options:
explicit_to_json: false explicit_to_json: false
checked: true
generic_argument_factories: true generic_argument_factories: true
+5 -22
View File
@@ -17,8 +17,7 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich /// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit /// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh /// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ / /// (Phone / 7″ / 10″).
/// iPad 13″).
/// ///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus /// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch. /// Fixtures, daher ist der Lauf deterministisch.
@@ -43,13 +42,11 @@ Future<void> main() async {
_log('Login'); _log('Login');
await _login(tester); await _login(tester);
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
// Post-Login-Aufbau deutlich länger als 20s.
_log('warte auf App-Shell'); _log('warte auf App-Shell');
await _pumpUntil( await _pumpUntil(
tester, tester,
find.byType(App), find.byType(App),
timeout: const Duration(seconds: 60), timeout: const Duration(seconds: 20),
); );
_log('warte auf Post-Login-Splash-Ende'); _log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone( await _pumpUntilGone(
@@ -67,12 +64,6 @@ Future<void> main() async {
await binding.convertFlutterSurfaceToImage(); await binding.convertFlutterSurfaceToImage();
} }
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
// Dialog würde sonst über der Talk-Liste liegen.
_markTalkPermissionPromptShown(tester);
// 1 + 2: Stundenplan in beiden Themes. // 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable); await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light); _setTheme(tester, ThemeMode.light);
@@ -115,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async { Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil( final loginVisible = await _pumpUntil(
tester, tester,
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-audience-school')),
); );
if (!loginVisible) { if (!loginVisible) {
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login'); _log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
return; return;
} }
await tester.tap(find.byKey(const Key('login-audience-school')));
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
await tester.enterText( await tester.enterText(
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-username-field')),
'demo@screenshots', 'demo@screenshots',
@@ -138,16 +131,6 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
context.read<SettingsCubit>().val(write: true).appTheme = mode; context.read<SettingsCubit>().val(write: true).appTheme = mode;
} }
void _markTalkPermissionPromptShown(WidgetTester tester) {
final context = tester.element(find.byType(App));
context
.read<SettingsCubit>()
.val(write: true)
.notificationSettings
.talkPermissionPromptShown =
true;
}
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt, /// 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. /// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async { Future<void> _goTo(WidgetTester tester, Modules module) async {
+6 -5
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -77,19 +77,19 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; }; 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; }; 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; }; 509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; }; 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; }; 60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; 64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -222,6 +222,7 @@
12B96C78930441C73F123636 /* Info.plist */, 12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */, 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
); );
name = NotificationServiceExtension;
path = NotificationServiceExtension; path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT; sourceTree = SOURCE_ROOT;
}; };
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git", "location" : "https://github.com/google/app-check.git",
"state" : { "state" : {
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.2" "version" : "11.3.1"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "27eaab3918e0bf78711cf1abf240577176326432", "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.19.0" "version" : "12.15.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee", "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.7.0" "version" : "3.6.1"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f", "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.19.2" "version" : "12.15.0"
} }
}, },
{ {
@@ -77,8 +77,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location" : "https://github.com/google/GoogleDataTransport.git",
"state" : { "state" : {
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", "revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.1" "version" : "10.1.0"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.3" "version" : "8.1.2"
} }
}, },
{ {
@@ -104,8 +104,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git", "location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : { "state" : {
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.1" "version" : "5.3.0"
} }
}, },
{ {
+6 -4
View File
@@ -4,10 +4,6 @@
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
@@ -41,6 +37,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@@ -70,6 +68,10 @@
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
+5
View File
@@ -4,6 +4,11 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:connect.marianum-fulda.de</string>
<string>applinks:connect-beta.marianum-fulda.de</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
+13 -86
View File
@@ -107,58 +107,20 @@ final class ShareViewController: UIViewController {
(UTType.url.identifier, .url), (UTType.url.identifier, .url),
(UTType.data.identifier, .file), (UTType.data.identifier, .file),
] ]
for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) { for (typeId, kind) in order {
// Konkreter Typ (z. B. public.png statt public.image) für Dateiendung if provider.hasItemConformingToTypeIdentifier(typeId) {
// und MIME-Type, falls die Quelle nur rohe Daten liefert.
let concrete = concreteType(of: provider, conformingTo: typeId)
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
guard let self else { completion(nil); return } guard let self else { completion(nil); return }
if error == nil, if error != nil { completion(nil); return }
let file = self.toSharedFile(data: data, kind: kind, completion(self.toSharedFile(data: data, kind: kind))
concrete: concrete, provider: provider) {
completion(file)
return
}
// Manche Quellen (z. B. der Screenshot-/Markup-Editor) liefern über
// loadItem nichts Verwertbares oder eine URL außerhalb unserer
// Sandbox. loadFileRepresentation stellt eine lesbare Kopie bereit.
self.loadFileFallback(provider: provider, kind: kind,
concrete: concrete, completion: completion)
} }
return return
} }
}
completion(nil) completion(nil)
} }
private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? { private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
guard let base = UTType(typeId) else { return nil }
return provider.registeredTypeIdentifiers
.compactMap { UTType($0) }
.first { $0.conforms(to: base) } ?? base
}
private func loadFileFallback(provider: NSItemProvider, kind: SharedMediaType,
concrete: UTType?,
completion: @escaping (SharedMediaFile?) -> Void) {
guard kind == .image || kind == .video || kind == .file, let concrete else {
completion(nil)
return
}
provider.loadFileRepresentation(forTypeIdentifier: concrete.identifier) { [weak self] url, _ in
// Die URL ist nur innerhalb dieses Callbacks gültig — sofort kopieren.
guard let self, let url, let dst = self.copyIntoAppGroup(src: url) else {
completion(nil)
return
}
let mime = self.mime(for: url) ?? concrete.preferredMIMEType
let duration = kind == .video ? self.videoDurationMs(url: dst) : nil
completion(SharedMediaFile(path: self.pathString(for: dst), mimeType: mime,
thumbnail: nil, duration: duration, message: nil, type: kind))
}
}
private func toSharedFile(data: Any?, kind: SharedMediaType, concrete: UTType?,
provider: NSItemProvider) -> SharedMediaFile? {
switch kind { switch kind {
case .text: case .text:
guard let s = data as? String else { return nil } guard let s = data as? String else { return nil }
@@ -177,10 +139,6 @@ final class ShareViewController: UIViewController {
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png", return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
thumbnail: nil, duration: nil, message: nil, type: .image) thumbnail: nil, duration: nil, message: nil, type: .image)
} }
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
thumbnail: nil, duration: nil, message: nil, type: .image)
}
return nil return nil
case .video: case .video:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil } guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
@@ -188,16 +146,10 @@ final class ShareViewController: UIViewController {
thumbnail: nil, duration: videoDurationMs(url: u), thumbnail: nil, duration: videoDurationMs(url: u),
message: nil, type: .video) message: nil, type: .video)
case .file: case .file:
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) { guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u), return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
thumbnail: nil, duration: nil, message: nil, type: .file) thumbnail: nil, duration: nil, message: nil, type: .file)
} }
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
thumbnail: nil, duration: nil, message: nil, type: .file)
}
return nil
}
} }
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell. // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
@@ -240,11 +192,13 @@ final class ShareViewController: UIViewController {
} }
private func copyIntoShareDir(src: URL) -> URL? { private func copyIntoShareDir(src: URL) -> URL? {
guard let dir = shareDir else { return nil }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
guard let dst = uniqueDestination(name: name) else { return nil } var dst = dir.appendingPathComponent(name)
// Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar. // Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
let scoped = src.startAccessingSecurityScopedResource() if FileManager.default.fileExists(atPath: dst.path) {
defer { if scoped { src.stopAccessingSecurityScopedResource() } } dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
}
do { do {
try FileManager.default.copyItem(at: src, to: dst) try FileManager.default.copyItem(at: src, to: dst)
return dst return dst
@@ -253,33 +207,6 @@ final class ShareViewController: UIViewController {
} }
} }
// Nur auf fileQueue aufrufen.
private func uniqueDestination(name: String) -> URL? {
guard let dir = shareDir else { return nil }
let dst = dir.appendingPathComponent(name)
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
if FileManager.default.fileExists(atPath: dst.path) {
return dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
}
return dst
}
private func writeData(_ data: Data, type: UTType?, provider: NSItemProvider) -> URL? {
let ext = type?.preferredFilenameExtension ?? "bin"
var base = (provider.suggestedName ?? UUID().uuidString)
.replacingOccurrences(of: "/", with: "-")
if (base as NSString).pathExtension.lowercased() == ext.lowercased() {
base = (base as NSString).deletingPathExtension
}
// Namenswahl und Schreiben zusammen seriell, sonst können zwei Anhänge
// mit gleichem suggestedName denselben Pfad bekommen.
return fileQueue.sync {
guard let dst = uniqueDestination(name: "\(base).\(ext)"),
(try? data.write(to: dst)) != nil else { return nil }
return dst
}
}
private func writePng(image: UIImage) -> URL? { private func writePng(image: UIImage) -> URL? {
guard let data = image.pngData(), guard let data = image.pngData(),
let dir = fileQueue.sync(execute: { shareDir }) else { return nil } let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
-2
View File
@@ -1,2 +0,0 @@
app_identifier("eu.mhsl.marianum.mobile.client")
team_id("MY55VF3KPG")
-27
View File
@@ -1,27 +0,0 @@
default_platform(:ios)
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
# iPad 13″ zu.
#
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
# selbst wird per .gitignore nicht eingecheckt.
platform :ios do
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_app_store(
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
skip_screenshots: false,
overwrite_screenshots: true,
screenshots_path: "./fastlane/screenshots",
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false,
force: true, # kein HTML-Vorschau-Bestätigungsschritt
)
end
end
-49
View File
@@ -1,49 +0,0 @@
# Fastlane – App-Store-Screenshots & Upload
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
Fixtures.
## 1. Screenshots aufnehmen
Aus dem **Client**-Wurzelverzeichnis:
```bash
tool/screenshots_ios.sh
```
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
(13″, 2064×2752) nacheinander durch und legt die PNGs in
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
Simulatoren legt das Skript selbst an.
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
## 2. Hochladen
fastlane installieren (`brew install fastlane`), in App Store Connect unter
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
Rolle *App-Manager* anlegen und als JSON ablegen:
```json
{
"key_id": "ABC123XYZ",
"issuer_id": "00000000-0000-0000-0000-000000000000",
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
"in_house": false
}
```
```bash
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
cd ios
fastlane upload_screenshots
```
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
Whitespace-only changes.
+17
View File
@@ -0,0 +1,17 @@
import '../session/session.dart';
/// Backend identity a feature needs. Modules and settings sections declare
/// these; anything whose requirements the session does not meet is hidden.
enum AccessRequirement {
nextcloud,
guardian;
bool isMetBy(Session? session) => switch (this) {
AccessRequirement.nextcloud => session?.nextcloud != null,
AccessRequirement.guardian => session is GuardianSession,
};
}
extension AccessRequirements on Set<AccessRequirement> {
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
}
+20
View File
@@ -0,0 +1,20 @@
/// Account role as reported by MarianumConnect. Only used for display and for
/// deriving policies; features gate on capabilities and requirements instead
/// of comparing roles.
enum UserRole {
student,
teacher,
staff,
parent,
unknown;
/// Unknown or missing values map to [unknown] so a new server-side role
/// never breaks older app versions.
static UserRole parse(String? wire) => switch (wire) {
'STUDENT' => UserRole.student,
'TEACHER' => UserRole.teacher,
'STAFF' => UserRole.staff,
'PARENT' => UserRole.parent,
_ => UserRole.unknown,
};
}
-5
View File
@@ -7,9 +7,4 @@ abstract class ApiResponse {
@JsonKey(includeIfNull: false) @JsonKey(includeIfNull: false)
Map<String, String>? headers; Map<String, String>? headers;
/// Entries the decoder skipped (see `decodeListLenient`), reported by the
/// API layer once the response is back on the main isolate.
@JsonKey(includeFromJson: false, includeToJson: false)
List<String> parseWarnings = const [];
} }
-6
View File
@@ -52,16 +52,10 @@ class CacheStore {
Future<Directory> _directory() { Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace); final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async { return _dirs[name] ??= () async {
try {
final base = await getApplicationCacheDirectory(); final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name'); final dir = Directory('${base.path}/$name');
await dir.create(recursive: true); await dir.create(recursive: true);
return dir; return dir;
} on Object {
// Not memoized (disk full at launch): the next call tries again.
unawaited(_dirs.remove(name));
rethrow;
}
}(); }();
} }
+23
View File
@@ -1,5 +1,7 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart'; import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart'; import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../demo_persona.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the /// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable /// demo persona sees every feature (incl. push) as available and the timetable
@@ -17,6 +19,27 @@ class DemoCapabilities {
userType: 'STUDENT', userType: 'STUDENT',
loaded: true, loaded: true,
); );
/// Guardian persona with two children, so the child switcher is visible.
static CapabilitiesState guardianState() => const CapabilitiesState(
pushNotifications: true,
userType: 'PARENT',
children: [
GuardianChild(
id: 'demo-child-1',
firstName: DemoPersona.studentFirstName,
lastName: 'Hoffmann',
className: DemoPersona.className,
),
GuardianChild(
id: 'demo-child-2',
firstName: 'Jonas',
lastName: 'Hoffmann',
className: '6a',
),
],
loaded: true,
);
} }
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive /// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
+273
View File
@@ -0,0 +1,273 @@
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../demo_persona.dart';
/// Demo inbox of the guardian persona: one letter per variant (information,
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
/// Child ids match [DemoCapabilities.guardianState].
class DemoParentLetters {
const DemoParentLetters._();
static const _lena = 'demo-child-1';
static const _jonas = 'demo-child-2';
static final Set<String> _readInSession = {};
static void markRead(String letterId) => _readInSession.add(letterId);
static ParentLetterListResponse list() {
final items = [for (final letter in _letters()) letter.summary];
return ParentLetterListResponse(
items: items,
unreadCount: items.where((letter) => !letter.read).length,
openCount: items
.where((letter) => letter.status == ParentLetterStatus.open)
.length,
);
}
static ParentLetterDetail detail(String letterId) {
final letters = _letters();
return letters.firstWhere(
(letter) => letter.summary.id == letterId,
orElse: () => letters.first,
);
}
static ParentLetterPerson _teacher(int index) =>
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
static const _participation = ParentLetterField(
id: 'participation',
type: ParentLetterFieldType.singleChoice,
label: 'Nimmt Ihr Kind teil?',
isRequired: true,
options: [
ParentLetterOption(id: 'yes', label: 'Ja'),
ParentLetterOption(id: 'no', label: 'Nein'),
],
);
static List<ParentLetterDetail> _letters() {
final now = DateTime.now();
ParentLetterDetail letter({
required String id,
required String subject,
required ParentLetterPerson sender,
required Duration age,
required String body,
required List<String> childIds,
bool read = true,
ParentLetterStatus status = ParentLetterStatus.info,
ParentLetterRequest? request,
List<ParentLetterChildState> children = const [],
List<ParentLetterAttachment> attachments = const [],
ParentLetterThread thread = const ParentLetterThread(enabled: true),
}) => ParentLetterDetail(
summary: ParentLetterSummary(
id: id,
subject: subject,
preview: body.replaceAll('\n', ' ').trim(),
sender: sender,
sentAt: now.subtract(age),
read: read || _readInSession.contains(id),
childIds: childIds,
attachmentCount: attachments.length,
status: status,
deadline: request?.deadline,
),
content: ParentLetterContent(
body: body,
attachments: attachments,
request: request,
children: children,
thread: thread,
),
);
return [
letter(
id: 'demo-letter-hike',
subject: 'Wandertag der ${DemoPersona.className}',
sender: _teacher(0),
age: const Duration(hours: 2),
read: false,
childIds: const [_lena],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
'Viele Grüße\n${DemoPersona.teachers[0].name}',
attachments: const [
ParentLetterAttachment(
id: 'demo-attachment-hike',
fileName: 'Wandertag.pdf',
mimeType: 'application/pdf',
size: 48211,
),
],
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.add(const Duration(days: 10)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-parents-evening',
subject: 'Einladung zum Elternabend der 6a',
sender: _teacher(1),
age: const Duration(days: 1),
read: false,
childIds: const [_jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
request: const ParentLetterRequest(),
children: const [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-school-festival',
subject: 'Schulfest: Wer kommt mit?',
sender: _teacher(5),
age: const Duration(days: 3),
childIds: const [_lena, _jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
'https://www.marianum-fulda.de',
request: ParentLetterRequest(
fields: const [_participation],
deadline: now.add(const Duration(days: 5)),
),
children: [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.done,
editable: true,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 2)),
respondedBy: const ParentLetterPerson(self: true),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
),
),
const ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-reports',
subject: 'Zeugnisausgabe und Unterrichtsschluss',
sender: _teacher(3),
age: const Duration(days: 7),
childIds: const [_lena, _jonas],
body:
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
'Busse fahren entsprechend früher.',
thread: ParentLetterThread(
enabled: true,
messages: [
ParentLetterThreadMessage(
id: 'demo-thread-1',
author: const ParentLetterPerson(self: true),
body: 'Gilt das auch für die Nachmittagsbetreuung?',
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
),
ParentLetterThreadMessage(
id: 'demo-thread-2',
author: _teacher(3),
body:
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
'Bedarf melden Sie sich bitte im Sekretariat.',
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
),
],
),
),
letter(
id: 'demo-letter-swimming',
subject: 'Einverständnis Schwimmunterricht',
sender: _teacher(6),
age: const Duration(days: 21),
childIds: const [_jonas],
status: ParentLetterStatus.done,
body:
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
'Einverständnis.',
request: const ParentLetterRequest(
fields: [_participation],
signatureRequired: true,
),
children: [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.done,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 20)),
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
signed: true,
),
),
],
thread: const ParentLetterThread(),
),
letter(
id: 'demo-letter-ski-trip',
subject: 'Anmeldung zur Skifreizeit',
sender: _teacher(6),
age: const Duration(days: 35),
childIds: const [_lena],
status: ParentLetterStatus.expired,
body:
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.subtract(const Duration(days: 14)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.expired,
),
],
thread: const ParentLetterThread(),
),
];
}
}
-28
View File
@@ -1,28 +0,0 @@
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import '../../model/endpoint_data.dart';
import '../errors/network_exception.dart';
import 'demo_mode.dart';
/// Demo sessions carry made-up credentials. Every request they sent to the
/// real Nextcloud would count as a failed login there and, in bulk, trip the
/// server's brute-force throttle for the whole IP (HTTP 429 for everyone
/// behind it). Installed app-wide via [http.runWithClient].
class DemoHttpClient extends http.BaseClient {
final http.Client _inner = IOClient();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (DemoMode.active && isNextcloudHost(request.url)) {
return Future.error(NetworkException.demoBlocked(request.url));
}
return _inner.send(request);
}
@override
void close() => _inner.close();
static bool isNextcloudHost(Uri url) =>
url.host == EndpointData().nextcloud().domain;
}
-2
View File
@@ -32,8 +32,6 @@ class DemoMarianumConnect {
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList(); return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
case 'timetable/holidays': case 'timetable/holidays':
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList(); return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'timetable/custom-events':
return DemoTimetable.customEvents().toJson();
case 'holidays': case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList(); return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search': case 'users/search':
+8
View File
@@ -22,6 +22,14 @@ class DemoMode {
static bool matches(String username) => static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix); username.trim().toLowerCase().startsWith(usernamePrefix);
/// Guardian logins are passwordless, so reviewers need a fixed address that
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
/// real e-mail address could start with.
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
static bool matchesGuardian(String email) =>
email.trim().toLowerCase() == guardianEmail;
/// True while the active session is a demo session. /// True while the active session is a demo session.
static bool get active => SessionManager().isDemo; static bool get active => SessionManager().isDemo;
} }
+12 -52
View File
@@ -10,7 +10,9 @@ import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart'; import 'app_exception.dart';
import 'auth_exception.dart';
import 'network_exception.dart'; import 'network_exception.dart';
import 'not_found_exception.dart';
import 'parse_exception.dart'; import 'parse_exception.dart';
import 'server_exception.dart'; import 'server_exception.dart';
import 'talk_exception.dart'; import 'talk_exception.dart';
@@ -54,74 +56,32 @@ AppException? _dioToAppException(DioException error) {
} }
} }
final RegExp _davException = RegExp(
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
);
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
/// The Sabre DAV error body of a failed WebDAV request: the exception class
/// (without its `Sabre\DAV\Exception\` namespace) and the server's message.
/// The class says what actually went wrong where the status is ambiguous —
/// MKCOL on an existing folder is a plain 405.
({String exception, String message})? parseDavError(String body) {
final exception = _davException.firstMatch(body)?.group(1);
if (exception == null || exception.isEmpty) return null;
return (
exception: exception.split('\\').last,
message: _davMessage.firstMatch(body)?.group(1) ?? '',
);
}
AppException? _davToAppException(
int status,
({String exception, String message}) dav,
String detail,
) {
final userMessage = switch (dav.exception) {
'MethodNotAllowed' when status == 405 =>
'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.',
'PreconditionFailed' =>
'Am Zielort existiert bereits ein Eintrag mit diesem Namen.',
'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.',
'Locked' =>
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
'InsufficientStorage' =>
'Der verfügbare Speicherplatz reicht dafür nicht aus.',
_ => null,
};
if (userMessage == null) return null;
return ServerException(
statusCode: status,
userMessage: userMessage,
technicalDetails: detail,
allowRetry: dav.exception == 'Locked',
);
}
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx. /// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
/// Its toString() dumps every response header, so the details keep only the /// Its toString() dumps every response header, so the details keep only the
/// status plus a trimmed body preview (same format as the Talk API errors). /// status plus a trimmed body preview (same format as the Talk API errors).
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final dav = parseDavError(error.body);
if (dav != null) {
final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
return _davToAppException(status, dav, detail) ??
exceptionForStatus(status, detail);
}
final preview = previewBody(error.body); final preview = previewBody(error.body);
final detail = preview.isEmpty final detail = preview.isEmpty
? 'HTTP $status' ? 'HTTP $status'
: 'HTTP $status body=$preview'; : 'HTTP $status body=$preview';
if (status == 429) { switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
case 403:
return AuthException.forbidden(technicalDetails: detail);
case 404:
return NotFoundException(technicalDetails: detail);
case 429:
return ServerException( return ServerException(
statusCode: status, statusCode: status,
userMessage: userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail, technicalDetails: detail,
); );
default:
return ServerException(statusCode: status, technicalDetails: detail);
} }
return exceptionForStatus(status, detail);
} }
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
-6
View File
@@ -13,10 +13,4 @@ class NetworkException extends AppException {
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
technicalDetails: technicalDetails, technicalDetails: technicalDetails,
); );
/// A request a demo session must never send (see `DemoHttpClient`).
factory NetworkException.demoBlocked(Uri url) => NetworkException(
userMessage: 'Im Demo-Modus nicht verfügbar.',
technicalDetails: 'demo mode blocked $url',
);
} }
+1 -1
View File
@@ -7,7 +7,6 @@ class ServerException extends AppException {
required this.statusCode, required this.statusCode,
String? userMessage, String? userMessage,
super.technicalDetails, super.technicalDetails,
super.allowRetry,
}) : super( }) : super(
// Status code intentionally not in the user message — it lives in // Status code intentionally not in the user message — it lives in
// technicalDetails behind "Details anzeigen". // technicalDetails behind "Details anzeigen".
@@ -15,5 +14,6 @@ class ServerException extends AppException {
userMessage ?? userMessage ??
'Der Server konnte die Anfrage gerade nicht verarbeiten. ' 'Der Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.', 'Bitte versuche es in einem Moment erneut.',
allowRetry: true,
); );
} }
+8 -20
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'errors/app_exception.dart';
import 'errors/auth_exception.dart'; import 'errors/auth_exception.dart';
import 'errors/network_exception.dart'; import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart'; import 'errors/not_found_exception.dart';
@@ -43,23 +42,12 @@ String httpErrorDetail(String label, String body, int status) {
: '$label -> HTTP $status body=$preview'; : '$label -> HTTP $status body=$preview';
} }
/// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as /// Throws the [AppException] matching a non-2xx HTTP [status], carrying
/// technical details: 401/403 map to auth errors, 404 to not-found, everything /// [detail] as technical details: 401/403 map to auth errors, 404 to
/// else to a generic server error. /// not-found, everything else to a generic server error.
AppException exceptionForStatus(int status, String detail) => switch (status) { Never throwForStatus(int status, String detail) {
401 => AuthException.unauthorized(technicalDetails: detail), if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
403 => AuthException.forbidden(technicalDetails: detail), if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
404 => NotFoundException(technicalDetails: detail), if (status == 404) throw NotFoundException(technicalDetails: detail);
_ => ServerException(statusCode: status, technicalDetails: detail), throw ServerException(statusCode: status, technicalDetails: detail);
};
/// Throws [exceptionForStatus].
Never throwForStatus(int status, String detail) =>
throw exceptionForStatus(status, detail);
/// Throws [exceptionForStatus] unless [response] is 2xx.
void ensureSuccess(String label, http.Response response) {
final status = response.statusCode;
if (status >= 200 && status < 300) return;
throwForStatus(status, httpErrorDetail(label, response.body, status));
} }
-46
View File
@@ -1,46 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
/// Decodes server lists element by element so one entry the model cannot map
/// (a field the server dropped, an enum value it added) is skipped and
/// recorded instead of failing the whole response and with it the screen.
///
/// Skipped entries are collected per isolate; the API layer drains them with
/// [drainDecodeWarnings] and reports them, so a drift in the server's format
/// still reaches us without taking the feature down for the user.
final List<String> _warnings = [];
List<T> decodeListLenient<T>(
Object? raw,
T Function(Map<String, dynamic>) fromJson, {
required String what,
}) {
final items = <T>[];
if (raw is! List<dynamic>) {
throw FormatException(
'expected a list of ${what}s, got ${raw.runtimeType}',
);
}
for (final entry in raw) {
try {
items.add(fromJson(entry as Map<String, dynamic>));
} on CheckedFromJsonException catch (e) {
_warnings.add('$what: $e');
} on TypeError catch (e) {
_warnings.add('$what: $e');
}
}
return items;
}
Set<T> decodeSetLenient<T>(
Object? raw,
T Function(Map<String, dynamic>) fromJson, {
required String what,
}) => decodeListLenient(raw, fromJson, what: what).toSet();
/// Returns and clears the entries skipped since the last call.
List<String> drainDecodeWarnings() {
final out = List<String>.of(_warnings);
_warnings.clear();
return out;
}
@@ -18,7 +18,13 @@ class DeleteAppPassword {
NextcloudOcs.uri('core/apppassword'), NextcloudOcs.uri('core/apppassword'),
// An explicit header may belong to an inactive account, so the active // An explicit header may belong to an inactive account, so the active
// session's headers must not be required then. // session's headers must not be required then.
headers: NextcloudOcs.headers(authorization: authorizationHeader), headers: authorizationHeader == null
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
); );
} }
} }
@@ -28,17 +28,23 @@ class GetAppPassword {
label, label,
() => _client.get( () => _client.get(
NextcloudOcs.uri('core/getapppassword'), NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers // Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one — // the app password, but an app password cannot mint another one —
// this endpoint requires the real password. // this endpoint requires the real password.
headers: NextcloudOcs.headers( 'Authorization': SessionManager()
authorization: SessionManager()
.requireNextcloud() .requireNextcloud()
.realPasswordBasicAuthHeader, .realPasswordBasicAuthHeader,
), },
), ),
))!; ))!;
ensureSuccess(label, response); if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
final json = jsonDecode(utf8.decode(response.bodyBytes)); final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data']; final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null; final appPassword = data is Map ? data['apppassword'] as String? : null;
@@ -5,8 +5,6 @@ import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../../json_lenient.dart';
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart'; import 'autocomplete_response.dart';
@@ -45,13 +43,6 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}', technicalDetails: 'core/autocomplete/get: ${response.body}',
); );
} }
final result = AutocompleteResponse.fromJson( return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
NextcloudOcs.decode(response.body),
);
ClientErrorReporter.reportDecodeWarnings(
'Autocomplete',
drainDecodeWarnings(),
);
return result;
} }
} }
@@ -1,6 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../json_lenient.dart';
import '../files_sharing/queries/share/share.dart'; import '../files_sharing/queries/share/share.dart';
part 'autocomplete_response.g.dart'; part 'autocomplete_response.g.dart';
@@ -15,7 +14,6 @@ int shareTypeFromSource(String? source) {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class AutocompleteResponse { class AutocompleteResponse {
@JsonKey(fromJson: _entriesFromJson)
List<AutocompleteResponseObject> data; List<AutocompleteResponseObject> data;
AutocompleteResponse(this.data); AutocompleteResponse(this.data);
@@ -25,31 +23,15 @@ class AutocompleteResponse {
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this); Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
} }
List<AutocompleteResponseObject> _entriesFromJson(Object? raw) =>
decodeListLenient(
raw,
AutocompleteResponseObject.fromJson,
what: 'autocomplete entry',
);
/// The server sends a user-status object for users that have one and an empty
/// string otherwise; only the status string itself is kept.
String? _statusFromJson(Object? raw) => switch (raw) {
Map<String, dynamic> status => status['status']?.toString(),
String status when status.isNotEmpty => status,
_ => null,
};
@JsonSerializable() @JsonSerializable()
class AutocompleteResponseObject { class AutocompleteResponseObject {
String id; String id;
String label; String label;
String? icon; String? icon;
String? source; String? source;
@JsonKey(fromJson: _statusFromJson)
String? status; String? status;
String? subline; String? subline;
String? shareWithDisplayNameUnique; String? shareWithDisplayNameUniqe;
AutocompleteResponseObject( AutocompleteResponseObject(
this.id, this.id,
@@ -58,7 +40,7 @@ class AutocompleteResponseObject {
this.source, this.source,
this.status, this.status,
this.subline, this.subline,
this.shareWithDisplayNameUnique, this.shareWithDisplayNameUniqe,
); );
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) => factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
@@ -8,12 +8,13 @@ part of 'autocomplete_response.dart';
AutocompleteResponse _$AutocompleteResponseFromJson( AutocompleteResponse _$AutocompleteResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('AutocompleteResponse', json, ($checkedConvert) { ) => AutocompleteResponse(
final val = AutocompleteResponse( (json['data'] as List<dynamic>)
$checkedConvert('data', (v) => _entriesFromJson(v)), .map(
(e) => AutocompleteResponseObject.fromJson(e as Map<String, dynamic>),
)
.toList(),
); );
return val;
});
Map<String, dynamic> _$AutocompleteResponseToJson( Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponse instance, AutocompleteResponse instance,
@@ -21,18 +22,15 @@ Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson( AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('AutocompleteResponseObject', json, ($checkedConvert) { ) => AutocompleteResponseObject(
final val = AutocompleteResponseObject( json['id'] as String,
$checkedConvert('id', (v) => v as String), json['label'] as String,
$checkedConvert('label', (v) => v as String), json['icon'] as String?,
$checkedConvert('icon', (v) => v as String?), json['source'] as String?,
$checkedConvert('source', (v) => v as String?), json['status'] as String?,
$checkedConvert('status', (v) => _statusFromJson(v)), json['subline'] as String?,
$checkedConvert('subline', (v) => v as String?), json['shareWithDisplayNameUniqe'] as String?,
$checkedConvert('shareWithDisplayNameUnique', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$AutocompleteResponseObjectToJson( Map<String, dynamic> _$AutocompleteResponseObjectToJson(
AutocompleteResponseObject instance, AutocompleteResponseObject instance,
@@ -43,5 +41,5 @@ Map<String, dynamic> _$AutocompleteResponseObjectToJson(
'source': instance.source, 'source': instance.source,
'status': instance.status, 'status': instance.status,
'subline': instance.subline, 'subline': instance.subline,
'shareWithDisplayNameUnique': instance.shareWithDisplayNameUnique, 'shareWithDisplayNameUniqe': instance.shareWithDisplayNameUniqe,
}; };
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -95,8 +96,9 @@ class GetUserInfo {
uri, uri,
); );
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>; (root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
@@ -8,19 +8,16 @@ part of 'file_sharing_api_params.dart';
FileSharingApiParams _$FileSharingApiParamsFromJson( FileSharingApiParams _$FileSharingApiParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('FileSharingApiParams', json, ($checkedConvert) { ) => FileSharingApiParams(
final val = FileSharingApiParams( shareType: (json['shareType'] as num).toInt(),
shareType: $checkedConvert('shareType', (v) => (v as num).toInt()), shareWith: json['shareWith'] as String,
shareWith: $checkedConvert('shareWith', (v) => v as String), path: json['path'] as String,
path: $checkedConvert('path', (v) => v as String), referenceId: json['referenceId'] as String?,
referenceId: $checkedConvert('referenceId', (v) => v as String?), talkMetaData: json['talkMetaData'] as String?,
talkMetaData: $checkedConvert('talkMetaData', (v) => v as String?), permissions: (json['permissions'] as num?)?.toInt(),
permissions: $checkedConvert('permissions', (v) => (v as num?)?.toInt()), password: json['password'] as String?,
password: $checkedConvert('password', (v) => v as String?), expireDate: json['expireDate'] as String?,
expireDate: $checkedConvert('expireDate', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$FileSharingApiParamsToJson( Map<String, dynamic> _$FileSharingApiParamsToJson(
FileSharingApiParams instance, FileSharingApiParams instance,
@@ -29,7 +29,12 @@ class LoginFlowApi {
headers: {'Accept': 'application/json', 'User-Agent': userAgent}, headers: {'Accept': 'application/json', 'User-Agent': userAgent},
), ),
))!; ))!;
ensureSuccess(label, response); if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowInit.fromJson( return LoginFlowInit.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
@@ -48,7 +53,12 @@ class LoginFlowApi {
), ),
))!; ))!;
if (response.statusCode == 404) return null; if (response.statusCode == 404) return null;
ensureSuccess(label, response); if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowCredentials.fromJson( return LoginFlowCredentials.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
+2 -5
View File
@@ -13,13 +13,10 @@ class NextcloudOcs {
static Map<String, dynamic> decode(String raw) => static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>; (jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
/// [authorization] overrides the active session's credentials, which are static Map<String, String> headers() => {
/// then not required.
static Map<String, String> headers({String? authorization}) => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': 'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
@@ -1,37 +0,0 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
import 'search_chat_messages_response.dart';
/// Wraps Talk's `talk-message-current` search provider, which searches the
/// whole server-side history of one conversation. The conversation is picked
/// via `from`, the route the provider would be opened from in the web UI.
class SearchChatMessages {
Future<SearchChatMessagesResponse> run({
required String chatToken,
required String term,
int limit = 25,
Object? cursor,
}) async {
final endpoint = NextcloudOcs.uri(
'search/providers/talk-message-current/search',
queryParameters: {
'term': term,
'from': '/call/$chatToken',
'limit': limit.toString(),
if (cursor != null) 'cursor': cursor.toString(),
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Chat search failed with ${response.statusCode}: ${response.body}',
);
}
final data =
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return SearchChatMessagesResponse.fromJson(data, term: term);
}
}
@@ -1,94 +0,0 @@
/// Parsed page of Talk's `talk-message-current` search provider.
class SearchChatMessagesResponse {
final List<SearchChatMessagesEntry> entries;
/// Opaque pagination cursor; `null` when the provider has no further page.
final Object? cursor;
const SearchChatMessagesResponse({required this.entries, this.cursor});
factory SearchChatMessagesResponse.fromJson(
Map<String, dynamic> json, {
required String term,
}) {
final rawEntries = json['entries'];
final entries = rawEntries is List
? rawEntries
.whereType<Map<String, dynamic>>()
.map((e) => SearchChatMessagesEntry.fromJson(e, term: term))
.whereType<SearchChatMessagesEntry>()
.toList()
: const <SearchChatMessagesEntry>[];
final paginated = json['isPaginated'] == true;
final cursor = json['cursor'];
return SearchChatMessagesResponse(
entries: entries,
cursor: paginated && entries.isNotEmpty && cursor != null ? cursor : null,
);
}
}
class SearchChatMessagesEntry {
final int messageId;
final String? actorId;
final String? actorDisplayName;
/// Server-built excerpt of the message, placeholders already resolved.
final String excerpt;
final DateTime? sentAt;
const SearchChatMessagesEntry({
required this.messageId,
required this.excerpt,
this.actorId,
this.actorDisplayName,
this.sentAt,
});
/// Returns `null` for entries without a recognisable message id.
static SearchChatMessagesEntry? fromJson(
Map<String, dynamic> json, {
required String term,
}) {
final attributes = json['attributes'];
final attrs = attributes is Map ? attributes : const {};
final messageId =
_parseInt(attrs['messageId']) ??
_parseInt(
RegExp(
r'#message_(\d+)',
).firstMatch(json['resourceUrl']?.toString() ?? '')?.group(1),
);
if (messageId == null) return null;
// Title and subline have swapped roles across Talk versions (author vs.
// excerpt); the one containing the search term is the excerpt.
final title = json['title']?.toString() ?? '';
final subline = json['subline']?.toString() ?? '';
final q = term.trim().toLowerCase();
final titleIsExcerpt =
q.isNotEmpty &&
title.toLowerCase().contains(q) &&
!subline.toLowerCase().contains(q);
final excerpt = titleIsExcerpt ? title : subline;
final author = titleIsExcerpt ? subline : title;
final timestamp = _parseInt(attrs['timestamp']);
final actorId = attrs['actorId']?.toString();
return SearchChatMessagesEntry(
messageId: messageId,
excerpt: excerpt,
actorId: actorId == null || actorId.isEmpty ? null : actorId,
actorDisplayName: author.isEmpty ? null : author,
sentAt: timestamp == null
? null
: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
);
}
static int? _parseInt(Object? raw) => switch (raw) {
final int v => v,
final String v => int.tryParse(v),
_ => null,
};
}
@@ -7,20 +7,14 @@ part of 'search_files_response.dart';
// ************************************************************************** // **************************************************************************
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) => SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('SearchFilesResponse', json, ($checkedConvert) { SearchFilesResponse(
final val = SearchFilesResponse( name: json['name'] as String,
name: $checkedConvert('name', (v) => v as String), isPaginated: json['isPaginated'] as bool,
isPaginated: $checkedConvert('isPaginated', (v) => v as bool), cursor: (json['cursor'] as num?)?.toInt(),
cursor: $checkedConvert('cursor', (v) => (v as num?)?.toInt()), entries: (json['entries'] as List<dynamic>)
entries: $checkedConvert(
'entries',
(v) => (v as List<dynamic>)
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>)) .map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
),
); );
return val;
});
Map<String, dynamic> _$SearchFilesResponseToJson( Map<String, dynamic> _$SearchFilesResponseToJson(
SearchFilesResponse instance, SearchFilesResponse instance,
@@ -32,19 +26,13 @@ Map<String, dynamic> _$SearchFilesResponseToJson(
}; };
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) => SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
$checkedCreate('SearchFilesEntry', json, ($checkedConvert) { SearchFilesEntry(
final val = SearchFilesEntry( title: json['title'] as String,
title: $checkedConvert('title', (v) => v as String), subline: json['subline'] as String?,
subline: $checkedConvert('subline', (v) => v as String?), icon: json['icon'] as String?,
icon: $checkedConvert('icon', (v) => v as String?), resourceUrl: json['resourceUrl'] as String?,
resourceUrl: $checkedConvert('resourceUrl', (v) => v as String?), attributes: json['attributes'] as Map<String, dynamic>?,
attributes: $checkedConvert(
'attributes',
(v) => v as Map<String, dynamic>?,
),
); );
return val;
});
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) => Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -1,46 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here
/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields
/// `null`.
Future<GetChatResponse?> fetchChatPage({
required String label,
required String chatToken,
required GetChatParams params,
Duration? timeout,
bool parseOffIsolate = false,
}) async {
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded('$label $uri', () {
final request = http.get(uri, headers: headers);
return timeout == null ? request : request.timeout(timeout);
}))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final parsed = parseOffIsolate
? await compute(_parseChatResponse, response.body)
: _parseChatResponse(response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: '$label $uri: HTTP $status',
);
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,3 +1,6 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
@@ -13,4 +16,11 @@ class GetChat extends TalkApi<GetChatResponse> {
@override @override
GetChatResponse assemble(String raw) => GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw)); GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,12 +1,17 @@
import 'fetch_chat_page.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`) /// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. The server /// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
/// answers HTTP 304 when there are no older messages left — a normal "start of /// because that layer treats non-2xx as errors, and the server answers HTTP 304
/// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into /// when there are no older messages left — a normal "start of chat" outcome here.
/// history never moves the read cursor. /// `setReadMarker=off` so paging into history never moves the read cursor.
class GetChatHistory { class GetChatHistory {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -19,18 +24,39 @@ class GetChatHistory {
}); });
/// Returns the older page, or `null` on HTTP 304 (no older messages). /// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() => fetchChatPage( Future<GetChatResponse?> run() async {
label: 'GetChatHistory', final params = GetChatParams(
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.off,
limit: limit, limit: limit,
), );
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
// A page holds up to 200 messages and lands while the user is scrolling; // A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling. // decoding it on the UI isolate stalls the fling.
parseOffIsolate: true, final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
); );
} }
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -7,33 +7,24 @@ part of 'get_chat_params.dart';
// ************************************************************************** // **************************************************************************
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) => GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetChatParams', json, ($checkedConvert) { GetChatParams(
final val = GetChatParams( lookIntoFuture: $enumDecode(
lookIntoFuture: $checkedConvert( _$GetChatParamsSwitchEnumMap,
'lookIntoFuture', json['lookIntoFuture'],
(v) => $enumDecode(_$GetChatParamsSwitchEnumMap, v),
), ),
limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()), limit: (json['limit'] as num?)?.toInt(),
lastKnownMessageId: $checkedConvert( lastKnownMessageId: (json['lastKnownMessageId'] as num?)?.toInt(),
'lastKnownMessageId', lastCommonReadId: (json['lastCommonReadId'] as num?)?.toInt(),
(v) => (v as num?)?.toInt(), timeout: (json['timeout'] as num?)?.toInt(),
setReadMarker: $enumDecodeNullable(
_$GetChatParamsSwitchEnumMap,
json['setReadMarker'],
), ),
lastCommonReadId: $checkedConvert( includeLastKnown: $enumDecodeNullable(
'lastCommonReadId', _$GetChatParamsSwitchEnumMap,
(v) => (v as num?)?.toInt(), json['includeLastKnown'],
),
timeout: $checkedConvert('timeout', (v) => (v as num?)?.toInt()),
setReadMarker: $checkedConvert(
'setReadMarker',
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
),
includeLastKnown: $checkedConvert(
'includeLastKnown',
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
), ),
); );
return val;
});
Map<String, dynamic> _$GetChatParamsToJson( Map<String, dynamic> _$GetChatParamsToJson(
GetChatParams instance, GetChatParams instance,
@@ -2,14 +2,12 @@ import 'package:json_annotation/json_annotation.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
import '../room/get_room_response.dart'; import '../room/get_room_response.dart';
part 'get_chat_response.g.dart'; part 'get_chat_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetChatResponse extends ApiResponse { class GetChatResponse extends ApiResponse {
@JsonKey(fromJson: _messagesFromJson)
Set<GetChatResponseObject> data; Set<GetChatResponseObject> data;
GetChatResponse(this.data); GetChatResponse(this.data);
@@ -25,20 +23,15 @@ class GetChatResponse extends ApiResponse {
} }
} }
Set<GetChatResponseObject> _messagesFromJson(Object? raw) =>
decodeSetLenient(raw, GetChatResponseObject.fromJson, what: 'message');
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetChatResponseObject { class GetChatResponseObject {
int id; int id;
String token; String token;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageActorType.unknown)
GetRoomResponseObjectMessageActorType actorType; GetRoomResponseObjectMessageActorType actorType;
String actorId; String actorId;
String actorDisplayName; String actorDisplayName;
int timestamp; int timestamp;
String systemMessage; String systemMessage;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageType.unknown)
GetRoomResponseObjectMessageType messageType; GetRoomResponseObjectMessageType messageType;
bool isReplyable; bool isReplyable;
String referenceId; String referenceId;
@@ -47,11 +40,6 @@ class GetChatResponseObject {
List<String>? reactionsSelf; List<String>? reactionsSelf;
@JsonKey(fromJson: _fromJson) @JsonKey(fromJson: _fromJson)
Map<String, RichObjectString>? messageParameters; Map<String, RichObjectString>? messageParameters;
/// The quoted message. When the original is no longer available (expired,
/// purged) the server only sends `{id, deleted: true}`; that becomes a
/// deleted-comment placeholder so the quote still renders.
@JsonKey(fromJson: _parentFromJson)
GetChatResponseObject? parent; GetChatResponseObject? parent;
/// Whether the server flagged this message to be rendered as Markdown /// Whether the server flagged this message to be rendered as Markdown
@@ -82,33 +70,10 @@ class GetChatResponseObject {
_$GetChatResponseObjectFromJson(json); _$GetChatResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
/// [timestamp] is in seconds. static GetChatResponseObject getDateDummy(int timestamp) {
DateTime get sentAt => DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(elementDate.formatDateRelativeShort());
static GetChatResponseObject getDateDummy(int timestamp) => getTextDummy( }
DateTime.fromMillisecondsSinceEpoch(
timestamp * 1000,
).formatDateRelativeShort(),
);
static GetChatResponseObject deletedPlaceholder(int id) =>
GetChatResponseObject(
id,
'',
GetRoomResponseObjectMessageActorType.user,
'',
'',
0,
'',
GetRoomResponseObjectMessageType.deletedComment,
false,
'',
'Nachricht wurde gelöscht',
null,
null,
null,
null,
);
static GetChatResponseObject getTextDummy(String text) => static GetChatResponseObject getTextDummy(String text) =>
GetChatResponseObject( GetChatResponseObject(
@@ -130,24 +95,14 @@ class GetChatResponseObject {
); );
} }
GetChatResponseObject? _parentFromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
if (json['deleted'] == true) {
return GetChatResponseObject.deletedPlaceholder(
(json['id'] as num?)?.toInt() ?? 0,
);
}
return GetChatResponseObject.fromJson(json);
}
Map<String, RichObjectString>? _fromJson(dynamic json) { Map<String, RichObjectString>? _fromJson(dynamic json) {
if (json is Map<String, dynamic>) { if (json is Map<String, dynamic>) {
final data = <String, RichObjectString>{}; final data = <String, RichObjectString>{};
for (final entry in json.entries) { for (final element in json.keys) {
final value = entry.value; data.putIfAbsent(
if (value is Map<String, dynamic>) { element,
data[entry.key] = RichObjectString.fromJson(value); () => RichObjectString.fromJson(json[element] as Map<String, dynamic>),
} );
} }
return data; return data;
} }
@@ -157,7 +112,6 @@ Map<String, RichObjectString>? _fromJson(dynamic json) {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class RichObjectString { class RichObjectString {
@JsonKey(unknownEnumValue: RichObjectStringObjectType.unknown)
RichObjectStringObjectType type; RichObjectStringObjectType type;
String id; String id;
String name; String name;
@@ -165,17 +119,7 @@ class RichObjectString {
String? path; String? path;
String? link; String? link;
/// Only set for `file` objects. RichObjectString(this.type, this.id, this.name, this.path, this.link);
String? mimetype;
RichObjectString(
this.type,
this.id,
this.name,
this.path,
this.link, [
this.mimetype,
]);
factory RichObjectString.fromJson(Map<String, dynamic> json) => factory RichObjectString.fromJson(Map<String, dynamic> json) =>
_$RichObjectStringFromJson(json); _$RichObjectStringFromJson(json);
@@ -199,14 +143,4 @@ enum RichObjectStringObjectType {
geoLocation, geoLocation,
@JsonValue('call') @JsonValue('call')
call, call,
@JsonValue('user-group')
userGroup,
@JsonValue('circle')
circle,
@JsonValue('email')
email,
/// Anything else (deck cards, contacts, calendar events, ...); the
/// placeholder still resolves to the object's name.
unknown,
} }
@@ -7,18 +7,16 @@ part of 'get_chat_response.dart';
// ************************************************************************** // **************************************************************************
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) => GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetChatResponse', json, ($checkedConvert) { GetChatResponse(
final val = GetChatResponse( (json['data'] as List<dynamic>)
$checkedConvert('data', (v) => _messagesFromJson(v)), .map(
); (e) => GetChatResponseObject.fromJson(e as Map<String, dynamic>),
$checkedConvert( )
'headers', .toSet(),
(v) => val.headers = (v as Map<String, dynamic>?)?.map( )
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) => Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -28,49 +26,31 @@ Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
GetChatResponseObject _$GetChatResponseObjectFromJson( GetChatResponseObject _$GetChatResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetChatResponseObject', json, ($checkedConvert) { ) => GetChatResponseObject(
final val = GetChatResponseObject( (json['id'] as num).toInt(),
$checkedConvert('id', (v) => (v as num).toInt()), json['token'] as String,
$checkedConvert('token', (v) => v as String), $enumDecode(
$checkedConvert(
'actorType',
(v) => $enumDecode(
_$GetRoomResponseObjectMessageActorTypeEnumMap, _$GetRoomResponseObjectMessageActorTypeEnumMap,
v, json['actorType'],
unknownValue: GetRoomResponseObjectMessageActorType.unknown,
), ),
), json['actorId'] as String,
$checkedConvert('actorId', (v) => v as String), json['actorDisplayName'] as String,
$checkedConvert('actorDisplayName', (v) => v as String), (json['timestamp'] as num).toInt(),
$checkedConvert('timestamp', (v) => (v as num).toInt()), json['systemMessage'] as String,
$checkedConvert('systemMessage', (v) => v as String), $enumDecode(_$GetRoomResponseObjectMessageTypeEnumMap, json['messageType']),
$checkedConvert( json['isReplyable'] as bool,
'messageType', json['referenceId'] as String,
(v) => $enumDecode( json['message'] as String,
_$GetRoomResponseObjectMessageTypeEnumMap, _fromJson(json['messageParameters']),
v, (json['reactions'] as Map<String, dynamic>?)?.map(
unknownValue: GetRoomResponseObjectMessageType.unknown,
),
),
$checkedConvert('isReplyable', (v) => v as bool),
$checkedConvert('referenceId', (v) => v as String),
$checkedConvert('message', (v) => v as String),
$checkedConvert('messageParameters', (v) => _fromJson(v)),
$checkedConvert(
'reactions',
(v) => (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()), (k, e) => MapEntry(k, (e as num).toInt()),
), ),
), (json['reactionsSelf'] as List<dynamic>?)?.map((e) => e as String).toList(),
$checkedConvert( json['parent'] == null
'reactionsSelf', ? null
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(), : GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>),
), json['markdown'] as bool? ?? false,
$checkedConvert('parent', (v) => _parentFromJson(v)),
$checkedConvert('markdown', (v) => v as bool? ?? false),
); );
return val;
});
Map<String, dynamic> _$GetChatResponseObjectToJson( Map<String, dynamic> _$GetChatResponseObjectToJson(
GetChatResponseObject instance, GetChatResponseObject instance,
@@ -103,12 +83,6 @@ const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
GetRoomResponseObjectMessageActorType.guest: 'guests', GetRoomResponseObjectMessageActorType.guest: 'guests',
GetRoomResponseObjectMessageActorType.bot: 'bots', GetRoomResponseObjectMessageActorType.bot: 'bots',
GetRoomResponseObjectMessageActorType.bridge: 'bridged', GetRoomResponseObjectMessageActorType.bridge: 'bridged',
GetRoomResponseObjectMessageActorType.group: 'groups',
GetRoomResponseObjectMessageActorType.email: 'emails',
GetRoomResponseObjectMessageActorType.circle: 'circles',
GetRoomResponseObjectMessageActorType.federatedUser: 'federated_users',
GetRoomResponseObjectMessageActorType.phone: 'phones',
GetRoomResponseObjectMessageActorType.unknown: 'unknown',
}; };
const _$GetRoomResponseObjectMessageTypeEnumMap = { const _$GetRoomResponseObjectMessageTypeEnumMap = {
@@ -117,28 +91,16 @@ const _$GetRoomResponseObjectMessageTypeEnumMap = {
GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted', GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted',
GetRoomResponseObjectMessageType.system: 'system', GetRoomResponseObjectMessageType.system: 'system',
GetRoomResponseObjectMessageType.command: 'command', GetRoomResponseObjectMessageType.command: 'command',
GetRoomResponseObjectMessageType.unknown: 'unknown',
}; };
RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) => RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
$checkedCreate('RichObjectString', json, ($checkedConvert) { RichObjectString(
final val = RichObjectString( $enumDecode(_$RichObjectStringObjectTypeEnumMap, json['type']),
$checkedConvert( json['id'] as String,
'type', json['name'] as String,
(v) => $enumDecode( json['path'] as String?,
_$RichObjectStringObjectTypeEnumMap, json['link'] as String?,
v,
unknownValue: RichObjectStringObjectType.unknown,
),
),
$checkedConvert('id', (v) => v as String),
$checkedConvert('name', (v) => v as String),
$checkedConvert('path', (v) => v as String?),
$checkedConvert('link', (v) => v as String?),
$checkedConvert('mimetype', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) => Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -147,7 +109,6 @@ Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
'name': instance.name, 'name': instance.name,
'path': instance.path, 'path': instance.path,
'link': instance.link, 'link': instance.link,
'mimetype': instance.mimetype,
}; };
const _$RichObjectStringObjectTypeEnumMap = { const _$RichObjectStringObjectTypeEnumMap = {
@@ -159,8 +120,4 @@ const _$RichObjectStringObjectTypeEnumMap = {
RichObjectStringObjectType.talkPoll: 'talk-poll', RichObjectStringObjectType.talkPoll: 'talk-poll',
RichObjectStringObjectType.geoLocation: 'geo-location', RichObjectStringObjectType.geoLocation: 'geo-location',
RichObjectStringObjectType.call: 'call', RichObjectStringObjectType.call: 'call',
RichObjectStringObjectType.userGroup: 'user-group',
RichObjectStringObjectType.circle: 'circle',
RichObjectStringObjectType.email: 'email',
RichObjectStringObjectType.unknown: 'unknown',
}; };
@@ -1,10 +1,15 @@
import 'fetch_chat_page.dart'; import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal /// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets /// because that layer treats non-2xx as errors, and we need 304 to be a
/// the server move the read cursor whenever the call returns messages. /// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
class LongPollChat { class LongPollChat {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -17,17 +22,37 @@ class LongPollChat {
}); });
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() => fetchChatPage( Future<GetChatResponse?> run() async {
label: 'LongPollChat', final params = GetChatParams(
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on, lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds, timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
limit: 100, limit: 100,
), );
timeout: Duration(seconds: timeoutSeconds + 15), final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
); );
} }
}
@@ -7,18 +7,14 @@ part of 'create_poll_params.dart';
// ************************************************************************** // **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) => CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('CreatePollParams', json, ($checkedConvert) { CreatePollParams(
final val = CreatePollParams( question: json['question'] as String,
question: $checkedConvert('question', (v) => v as String), options: (json['options'] as List<dynamic>)
options: $checkedConvert( .map((e) => e as String)
'options', .toList(),
(v) => (v as List<dynamic>).map((e) => e as String).toList(), resultMode: (json['resultMode'] as num).toInt(),
), maxVotes: (json['maxVotes'] as num).toInt(),
resultMode: $checkedConvert('resultMode', (v) => (v as num).toInt()),
maxVotes: $checkedConvert('maxVotes', (v) => (v as num).toInt()),
); );
return val;
});
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) => Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,17 +7,14 @@ part of 'create_room_params.dart';
// ************************************************************************** // **************************************************************************
CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) => CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('CreateRoomParams', json, ($checkedConvert) { CreateRoomParams(
final val = CreateRoomParams( roomType: (json['roomType'] as num).toInt(),
roomType: $checkedConvert('roomType', (v) => (v as num).toInt()), invite: json['invite'] as String,
invite: $checkedConvert('invite', (v) => v as String), source: json['source'] as String?,
source: $checkedConvert('source', (v) => v as String?), roomName: json['roomName'] as String?,
roomName: $checkedConvert('roomName', (v) => v as String?), objectType: json['objectType'] as String?,
objectType: $checkedConvert('objectType', (v) => v as String?), objectId: json['objectId'] as String?,
objectId: $checkedConvert('objectId', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) => Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,21 +7,12 @@ part of 'create_room_response.dart';
// ************************************************************************** // **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) => CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('CreateRoomResponse', json, ($checkedConvert) { CreateRoomResponse(
final val = CreateRoomResponse( CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
$checkedConvert( )
'data', ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(v) => CreateRoomResponseData.fromJson(v as Map<String, dynamic>),
),
);
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) => Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -31,12 +22,7 @@ Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
CreateRoomResponseData _$CreateRoomResponseDataFromJson( CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('CreateRoomResponseData', json, ($checkedConvert) { ) => CreateRoomResponseData(json['token'] as String);
final val = CreateRoomResponseData(
$checkedConvert('token', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$CreateRoomResponseDataToJson( Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance, CreateRoomResponseData instance,
@@ -8,12 +8,7 @@ part of 'delete_react_message_params.dart';
DeleteReactMessageParams _$DeleteReactMessageParamsFromJson( DeleteReactMessageParams _$DeleteReactMessageParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('DeleteReactMessageParams', json, ($checkedConvert) { ) => DeleteReactMessageParams(json['reaction'] as String);
final val = DeleteReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$DeleteReactMessageParamsToJson( Map<String, dynamic> _$DeleteReactMessageParamsToJson(
DeleteReactMessageParams instance, DeleteReactMessageParams instance,
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_participants_response.dart'; import 'get_participants_response.dart';
@@ -9,4 +11,11 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
@override @override
GetParticipantsResponse assemble(String raw) => GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw)); GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,13 +1,11 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
part 'get_participants_response.g.dart'; part 'get_participants_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetParticipantsResponse extends ApiResponse { class GetParticipantsResponse extends ApiResponse {
@JsonKey(fromJson: _participantsFromJson)
Set<GetParticipantsResponseObject> data; Set<GetParticipantsResponseObject> data;
GetParticipantsResponse(this.data); GetParticipantsResponse(this.data);
@@ -17,25 +15,15 @@ class GetParticipantsResponse extends ApiResponse {
Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this); Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this);
} }
Set<GetParticipantsResponseObject> _participantsFromJson(Object? raw) =>
decodeSetLenient(
raw,
GetParticipantsResponseObject.fromJson,
what: 'participant',
);
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetParticipantsResponseObject { class GetParticipantsResponseObject {
int attendeeId; int attendeeId;
String actorType; String actorType;
String actorId; String actorId;
String displayName; String displayName;
@JsonKey(unknownEnumValue: GetParticipantsResponseObjectParticipantType.user)
GetParticipantsResponseObjectParticipantType participantType; GetParticipantsResponseObjectParticipantType participantType;
int lastPing; int lastPing;
GetParticipantsResponseObjectParticipantsInCallFlags inCall;
/// Bitmask: 1 = in call, 2 = with audio, 4 = with video, 8 = via phone.
int inCall;
int permissions; int permissions;
int attendeePermissions; int attendeePermissions;
String? sessionId; String? sessionId;
@@ -66,11 +54,6 @@ class GetParticipantsResponseObject {
factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) => factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetParticipantsResponseObjectFromJson(json); _$GetParticipantsResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this);
bool get isInCall => inCall & 1 != 0;
bool get inCallWithAudio => inCall & 2 != 0;
bool get inCallWithVideo => inCall & 4 != 0;
bool get inCallViaPhone => inCall & 8 != 0;
} }
enum GetParticipantsResponseObjectParticipantType { enum GetParticipantsResponseObjectParticipantType {
@@ -90,3 +73,16 @@ enum GetParticipantsResponseObjectParticipantType {
const GetParticipantsResponseObjectParticipantType(this.prettyName); const GetParticipantsResponseObjectParticipantType(this.prettyName);
final String prettyName; final String prettyName;
} }
enum GetParticipantsResponseObjectParticipantsInCallFlags {
@JsonValue(0)
disconnected,
@JsonValue(1)
inCall,
@JsonValue(2)
providesAudio,
@JsonValue(3)
providesVideo,
@JsonValue(4)
usesSipDialIn,
}
@@ -8,18 +8,19 @@ part of 'get_participants_response.dart';
GetParticipantsResponse _$GetParticipantsResponseFromJson( GetParticipantsResponse _$GetParticipantsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetParticipantsResponse', json, ($checkedConvert) { ) =>
final val = GetParticipantsResponse( GetParticipantsResponse(
$checkedConvert('data', (v) => _participantsFromJson(v)), (json['data'] as List<dynamic>)
); .map(
$checkedConvert( (e) => GetParticipantsResponseObject.fromJson(
'headers', e as Map<String, dynamic>,
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
), ),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
); );
return val;
});
Map<String, dynamic> _$GetParticipantsResponseToJson( Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponse instance, GetParticipantsResponse instance,
@@ -30,36 +31,29 @@ Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson( GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetParticipantsResponseObject', json, ($checkedConvert) { ) => GetParticipantsResponseObject(
final val = GetParticipantsResponseObject( (json['attendeeId'] as num).toInt(),
$checkedConvert('attendeeId', (v) => (v as num).toInt()), json['actorType'] as String,
$checkedConvert('actorType', (v) => v as String), json['actorId'] as String,
$checkedConvert('actorId', (v) => v as String), json['displayName'] as String,
$checkedConvert('displayName', (v) => v as String), $enumDecode(
$checkedConvert(
'participantType',
(v) => $enumDecode(
_$GetParticipantsResponseObjectParticipantTypeEnumMap, _$GetParticipantsResponseObjectParticipantTypeEnumMap,
v, json['participantType'],
unknownValue: GetParticipantsResponseObjectParticipantType.user,
), ),
(json['lastPing'] as num).toInt(),
$enumDecode(
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap,
json['inCall'],
), ),
$checkedConvert('lastPing', (v) => (v as num).toInt()), (json['permissions'] as num).toInt(),
$checkedConvert('inCall', (v) => (v as num).toInt()), (json['attendeePermissions'] as num).toInt(),
$checkedConvert('permissions', (v) => (v as num).toInt()), json['sessionId'] as String?,
$checkedConvert('attendeePermissions', (v) => (v as num).toInt()), (json['sessionIds'] as List<dynamic>).map((e) => e as String).toList(),
$checkedConvert('sessionId', (v) => v as String?), json['status'] as String?,
$checkedConvert( json['statusIcon'] as String?,
'sessionIds', json['statusMessage'] as String?,
(v) => (v as List<dynamic>).map((e) => e as String).toList(), json['roomToken'] as String?,
),
$checkedConvert('status', (v) => v as String?),
$checkedConvert('statusIcon', (v) => v as String?),
$checkedConvert('statusMessage', (v) => v as String?),
$checkedConvert('roomToken', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$GetParticipantsResponseObjectToJson( Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
GetParticipantsResponseObject instance, GetParticipantsResponseObject instance,
@@ -72,7 +66,9 @@ Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
_$GetParticipantsResponseObjectParticipantTypeEnumMap[instance _$GetParticipantsResponseObjectParticipantTypeEnumMap[instance
.participantType]!, .participantType]!,
'lastPing': instance.lastPing, 'lastPing': instance.lastPing,
'inCall': instance.inCall, 'inCall':
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap[instance
.inCall]!,
'permissions': instance.permissions, 'permissions': instance.permissions,
'attendeePermissions': instance.attendeePermissions, 'attendeePermissions': instance.attendeePermissions,
'sessionId': instance.sessionId, 'sessionId': instance.sessionId,
@@ -91,3 +87,11 @@ const _$GetParticipantsResponseObjectParticipantTypeEnumMap = {
GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5, GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5,
GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6, GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6,
}; };
const _$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap = {
GetParticipantsResponseObjectParticipantsInCallFlags.disconnected: 0,
GetParticipantsResponseObjectParticipantsInCallFlags.inCall: 1,
GetParticipantsResponseObjectParticipantsInCallFlags.providesAudio: 2,
GetParticipantsResponseObjectParticipantsInCallFlags.providesVideo: 3,
GetParticipantsResponseObjectParticipantsInCallFlags.usesSipDialIn: 4,
};
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_poll_state_response.dart'; import 'get_poll_state_response.dart';
@@ -11,4 +13,11 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
@override @override
GetPollStateResponse assemble(String raw) => GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -8,21 +8,15 @@ part of 'get_poll_state_response.dart';
GetPollStateResponse _$GetPollStateResponseFromJson( GetPollStateResponse _$GetPollStateResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetPollStateResponse', json, ($checkedConvert) { ) =>
final val = GetPollStateResponse( GetPollStateResponse(
$checkedConvert( GetPollStateResponseObject.fromJson(
'data', json['data'] as Map<String, dynamic>,
(v) => GetPollStateResponseObject.fromJson(v as Map<String, dynamic>),
), ),
); )
$checkedConvert( ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetPollStateResponseToJson( Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponse instance, GetPollStateResponse instance,
@@ -33,30 +27,21 @@ Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponseObject _$GetPollStateResponseObjectFromJson( GetPollStateResponseObject _$GetPollStateResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetPollStateResponseObject', json, ($checkedConvert) { ) => GetPollStateResponseObject(
final val = GetPollStateResponseObject( (json['id'] as num).toInt(),
$checkedConvert('id', (v) => (v as num).toInt()), json['question'] as String,
$checkedConvert('question', (v) => v as String), (json['options'] as List<dynamic>).map((e) => e as String).toList(),
$checkedConvert( json['votes'],
'options', json['actorType'] as String,
(v) => (v as List<dynamic>).map((e) => e as String).toList(), json['actorId'] as String,
), json['actorDisplayName'] as String,
$checkedConvert('votes', (v) => v), (json['status'] as num).toInt(),
$checkedConvert('actorType', (v) => v as String), (json['resultMode'] as num).toInt(),
$checkedConvert('actorId', (v) => v as String), (json['maxVotes'] as num).toInt(),
$checkedConvert('actorDisplayName', (v) => v as String), (json['votedSelf'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
$checkedConvert('status', (v) => (v as num).toInt()), (json['numVoters'] as num?)?.toInt(),
$checkedConvert('resultMode', (v) => (v as num).toInt()), json['details'] as List<dynamic>?,
$checkedConvert('maxVotes', (v) => (v as num).toInt()),
$checkedConvert(
'votedSelf',
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
),
$checkedConvert('numVoters', (v) => (v as num?)?.toInt()),
$checkedConvert('details', (v) => v as List<dynamic>?),
); );
return val;
});
Map<String, dynamic> _$GetPollStateResponseObjectToJson( Map<String, dynamic> _$GetPollStateResponseObjectToJson(
GetPollStateResponseObject instance, GetPollStateResponseObject instance,
@@ -1,3 +1,7 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_reactions_response.dart'; import 'get_reactions_response.dart';
@@ -11,4 +15,11 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
@override @override
GetReactionsResponse assemble(String raw) => GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw)); GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -17,7 +17,6 @@ class GetReactionsResponse extends ApiResponse {
@JsonSerializable() @JsonSerializable()
class GetReactionsResponseObject { class GetReactionsResponseObject {
@JsonKey(unknownEnumValue: GetReactionsResponseObjectActorType.unknown)
GetReactionsResponseObjectActorType actorType; GetReactionsResponseObjectActorType actorType;
String actorId; String actorId;
String actorDisplayName; String actorDisplayName;
@@ -40,11 +39,4 @@ enum GetReactionsResponseObjectActorType {
guests, guests,
@JsonValue('users') @JsonValue('users')
users, users,
@JsonValue('emails')
emails,
@JsonValue('bots')
bots,
@JsonValue('federated_users')
federatedUsers,
unknown,
} }
@@ -8,11 +8,9 @@ part of 'get_reactions_response.dart';
GetReactionsResponse _$GetReactionsResponseFromJson( GetReactionsResponse _$GetReactionsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetReactionsResponse', json, ($checkedConvert) { ) =>
final val = GetReactionsResponse( GetReactionsResponse(
$checkedConvert( (json['data'] as Map<String, dynamic>).map(
'data',
(v) => (v as Map<String, dynamic>).map(
(k, e) => MapEntry( (k, e) => MapEntry(
k, k,
(e as List<dynamic>) (e as List<dynamic>)
@@ -24,16 +22,10 @@ GetReactionsResponse _$GetReactionsResponseFromJson(
.toList(), .toList(),
), ),
), ),
), )
); ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetReactionsResponseToJson( Map<String, dynamic> _$GetReactionsResponseToJson(
GetReactionsResponse instance, GetReactionsResponse instance,
@@ -46,22 +38,12 @@ Map<String, dynamic> _$GetReactionsResponseToJson(
GetReactionsResponseObject _$GetReactionsResponseObjectFromJson( GetReactionsResponseObject _$GetReactionsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetReactionsResponseObject', json, ($checkedConvert) { ) => GetReactionsResponseObject(
final val = GetReactionsResponseObject( $enumDecode(_$GetReactionsResponseObjectActorTypeEnumMap, json['actorType']),
$checkedConvert( json['actorId'] as String,
'actorType', json['actorDisplayName'] as String,
(v) => $enumDecode( (json['timestamp'] as num).toInt(),
_$GetReactionsResponseObjectActorTypeEnumMap,
v,
unknownValue: GetReactionsResponseObjectActorType.unknown,
),
),
$checkedConvert('actorId', (v) => v as String),
$checkedConvert('actorDisplayName', (v) => v as String),
$checkedConvert('timestamp', (v) => (v as num).toInt()),
); );
return val;
});
Map<String, dynamic> _$GetReactionsResponseObjectToJson( Map<String, dynamic> _$GetReactionsResponseObjectToJson(
GetReactionsResponseObject instance, GetReactionsResponseObject instance,
@@ -76,8 +58,4 @@ Map<String, dynamic> _$GetReactionsResponseObjectToJson(
const _$GetReactionsResponseObjectActorTypeEnumMap = { const _$GetReactionsResponseObjectActorTypeEnumMap = {
GetReactionsResponseObjectActorType.guests: 'guests', GetReactionsResponseObjectActorType.guests: 'guests',
GetReactionsResponseObjectActorType.users: 'users', GetReactionsResponseObjectActorType.users: 'users',
GetReactionsResponseObjectActorType.emails: 'emails',
GetReactionsResponseObjectActorType.bots: 'bots',
GetReactionsResponseObjectActorType.federatedUsers: 'federated_users',
GetReactionsResponseObjectActorType.unknown: 'unknown',
}; };
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_shared_items_response.dart'; import 'get_shared_items_response.dart';
@@ -28,4 +30,11 @@ class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
@override @override
GetSharedItemsResponse assemble(String raw) => GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_shared_items_overview_response.dart'; import 'get_shared_items_overview_response.dart';
@@ -16,4 +18,11 @@ class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
@override @override
GetSharedItemsOverviewResponse assemble(String raw) => GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -7,12 +7,7 @@ part of 'react_message_params.dart';
// ************************************************************************** // **************************************************************************
ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) => ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('ReactMessageParams', json, ($checkedConvert) { ReactMessageParams(json['reaction'] as String);
final val = ReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) => Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) =>
<String, dynamic>{'reaction': instance.reaction}; <String, dynamic>{'reaction': instance.reaction};
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../json_lenient.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_room_params.dart'; import 'get_room_params.dart';
@@ -17,8 +17,14 @@ class GetRoom extends TalkApi<GetRoomResponse> {
@override @override
Future<GetRoomResponse> assembleAsync(String raw) => Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw); compute(_parseRooms, raw);
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
GetRoomResponse _parseRooms(String raw) => GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw)) GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
..parseWarnings = drainDecodeWarnings();
@@ -4,7 +4,7 @@ import '../../../api_params.dart';
part 'get_room_params.g.dart'; part 'get_room_params.g.dart';
@JsonSerializable(explicitToJson: true, includeIfNull: false) @JsonSerializable(explicitToJson: true)
class GetRoomParams extends ApiParams { class GetRoomParams extends ApiParams {
GetRoomParamsStatusUpdate? noStatusUpdate; GetRoomParamsStatusUpdate? noStatusUpdate;
@JsonKey(toJson: _format) @JsonKey(toJson: _format)
@@ -7,27 +7,21 @@ part of 'get_room_params.dart';
// ************************************************************************** // **************************************************************************
GetRoomParams _$GetRoomParamsFromJson(Map<String, dynamic> json) => GetRoomParams _$GetRoomParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetRoomParams', json, ($checkedConvert) { GetRoomParams(
final val = GetRoomParams( noStatusUpdate: $enumDecodeNullable(
noStatusUpdate: $checkedConvert( _$GetRoomParamsStatusUpdateEnumMap,
'noStatusUpdate', json['noStatusUpdate'],
(v) => $enumDecodeNullable(_$GetRoomParamsStatusUpdateEnumMap, v),
),
includeStatus: $checkedConvert('includeStatus', (v) => v as bool?),
modifiedSince: $checkedConvert(
'modifiedSince',
(v) => (v as num?)?.toInt(),
), ),
includeStatus: json['includeStatus'] as bool?,
modifiedSince: (json['modifiedSince'] as num?)?.toInt(),
); );
return val;
});
Map<String, dynamic> _$GetRoomParamsToJson(GetRoomParams instance) => Map<String, dynamic> _$GetRoomParamsToJson(
<String, dynamic>{ GetRoomParams instance,
'noStatusUpdate': ) => <String, dynamic>{
?_$GetRoomParamsStatusUpdateEnumMap[instance.noStatusUpdate], 'noStatusUpdate': _$GetRoomParamsStatusUpdateEnumMap[instance.noStatusUpdate],
'includeStatus': GetRoomParams._format(instance.includeStatus), 'includeStatus': GetRoomParams._format(instance.includeStatus),
'modifiedSince': ?instance.modifiedSince, 'modifiedSince': instance.modifiedSince,
}; };
const _$GetRoomParamsStatusUpdateEnumMap = { const _$GetRoomParamsStatusUpdateEnumMap = {
@@ -1,14 +1,12 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
import '../chat/get_chat_response.dart'; import '../chat/get_chat_response.dart';
part 'get_room_response.g.dart'; part 'get_room_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetRoomResponse extends ApiResponse { class GetRoomResponse extends ApiResponse {
@JsonKey(fromJson: _roomsFromJson)
Set<GetRoomResponseObject> data; Set<GetRoomResponseObject> data;
GetRoomResponse(this.data); GetRoomResponse(this.data);
@@ -35,14 +33,10 @@ class GetRoomResponse extends ApiResponse {
} }
} }
Set<GetRoomResponseObject> _roomsFromJson(Object? raw) =>
decodeSetLenient(raw, GetRoomResponseObject.fromJson, what: 'room');
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetRoomResponseObject { class GetRoomResponseObject {
int id; int id;
String token; String token;
@JsonKey(unknownEnumValue: GetRoomResponseObjectConversationType.unknown)
GetRoomResponseObjectConversationType type; GetRoomResponseObjectConversationType type;
String name; String name;
String displayName; String displayName;
@@ -61,20 +55,13 @@ class GetRoomResponseObject {
bool canLeaveConversation; bool canLeaveConversation;
int lastActivity; int lastActivity;
bool isFavorite; bool isFavorite;
@JsonKey(
unknownEnumValue:
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
)
GetRoomResponseObjectParticipantNotificationLevel notificationLevel; GetRoomResponseObjectParticipantNotificationLevel notificationLevel;
int unreadMessages; int unreadMessages;
bool unreadMention; bool unreadMention;
bool unreadMentionDirect; bool unreadMentionDirect;
int lastReadMessage; int lastReadMessage;
int lastCommonReadMessage; int lastCommonReadMessage;
GetChatResponseObject lastMessage;
/// Omitted by the server for rooms without a visible message (empty room,
/// last message expired or hidden from this user, federated conversation).
GetChatResponseObject? lastMessage;
String? status; String? status;
String? statusIcon; String? statusIcon;
String? statusMessage; String? statusMessage;
@@ -130,9 +117,6 @@ enum GetRoomResponseObjectConversationType {
deleted, deleted,
@JsonValue(6) @JsonValue(6)
noteToSelf, noteToSelf,
/// Fallback for types this app does not know yet; treated like a group.
unknown,
} }
enum GetRoomResponseObjectParticipantNotificationLevel { enum GetRoomResponseObjectParticipantNotificationLevel {
@@ -163,17 +147,6 @@ enum GetRoomResponseObjectMessageActorType {
bot, bot,
@JsonValue('bridged') @JsonValue('bridged')
bridge, bridge,
@JsonValue('groups')
group,
@JsonValue('emails')
email,
@JsonValue('circles')
circle,
@JsonValue('federated_users')
federatedUser,
@JsonValue('phones')
phone,
unknown,
} }
enum GetRoomResponseObjectMessageType { enum GetRoomResponseObjectMessageType {
@@ -187,8 +160,4 @@ enum GetRoomResponseObjectMessageType {
system, system,
@JsonValue('command') @JsonValue('command')
command, command,
/// Fallback for types this app does not know yet (e.g. call recordings);
/// rendered like a plain comment.
unknown,
} }
@@ -7,18 +7,16 @@ part of 'get_room_response.dart';
// ************************************************************************** // **************************************************************************
GetRoomResponse _$GetRoomResponseFromJson(Map<String, dynamic> json) => GetRoomResponse _$GetRoomResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetRoomResponse', json, ($checkedConvert) { GetRoomResponse(
final val = GetRoomResponse( (json['data'] as List<dynamic>)
$checkedConvert('data', (v) => _roomsFromJson(v)), .map(
); (e) => GetRoomResponseObject.fromJson(e as Map<String, dynamic>),
$checkedConvert( )
'headers', .toSet(),
(v) => val.headers = (v as Map<String, dynamic>?)?.map( )
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) => Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -28,61 +26,41 @@ Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
GetRoomResponseObject _$GetRoomResponseObjectFromJson( GetRoomResponseObject _$GetRoomResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetRoomResponseObject', json, ($checkedConvert) { ) => GetRoomResponseObject(
final val = GetRoomResponseObject( (json['id'] as num).toInt(),
$checkedConvert('id', (v) => (v as num).toInt()), json['token'] as String,
$checkedConvert('token', (v) => v as String), $enumDecode(_$GetRoomResponseObjectConversationTypeEnumMap, json['type']),
$checkedConvert( json['name'] as String,
'type', json['displayName'] as String,
(v) => $enumDecode( json['description'] as String,
_$GetRoomResponseObjectConversationTypeEnumMap, (json['participantType'] as num).toInt(),
v, (json['participantFlags'] as num).toInt(),
unknownValue: GetRoomResponseObjectConversationType.unknown, (json['readOnly'] as num).toInt(),
), (json['listable'] as num).toInt(),
), (json['lastPing'] as num).toInt(),
$checkedConvert('name', (v) => v as String), json['sessionId'] as String,
$checkedConvert('displayName', (v) => v as String), json['hasPassword'] as bool,
$checkedConvert('description', (v) => v as String), json['hasCall'] as bool,
$checkedConvert('participantType', (v) => (v as num).toInt()), (json['callFlag'] as num).toInt(),
$checkedConvert('participantFlags', (v) => (v as num).toInt()), json['canStartCall'] as bool,
$checkedConvert('readOnly', (v) => (v as num).toInt()), json['canDeleteConversation'] as bool,
$checkedConvert('listable', (v) => (v as num).toInt()), json['canLeaveConversation'] as bool,
$checkedConvert('lastPing', (v) => (v as num).toInt()), (json['lastActivity'] as num).toInt(),
$checkedConvert('sessionId', (v) => v as String), json['isFavorite'] as bool,
$checkedConvert('hasPassword', (v) => v as bool), $enumDecode(
$checkedConvert('hasCall', (v) => v as bool),
$checkedConvert('callFlag', (v) => (v as num).toInt()),
$checkedConvert('canStartCall', (v) => v as bool),
$checkedConvert('canDeleteConversation', (v) => v as bool),
$checkedConvert('canLeaveConversation', (v) => v as bool),
$checkedConvert('lastActivity', (v) => (v as num).toInt()),
$checkedConvert('isFavorite', (v) => v as bool),
$checkedConvert(
'notificationLevel',
(v) => $enumDecode(
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap, _$GetRoomResponseObjectParticipantNotificationLevelEnumMap,
v, json['notificationLevel'],
unknownValue:
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
), ),
), (json['unreadMessages'] as num).toInt(),
$checkedConvert('unreadMessages', (v) => (v as num).toInt()), json['unreadMention'] as bool,
$checkedConvert('unreadMention', (v) => v as bool), json['unreadMentionDirect'] as bool,
$checkedConvert('unreadMentionDirect', (v) => v as bool), (json['lastReadMessage'] as num).toInt(),
$checkedConvert('lastReadMessage', (v) => (v as num).toInt()), (json['lastCommonReadMessage'] as num).toInt(),
$checkedConvert('lastCommonReadMessage', (v) => (v as num).toInt()), GetChatResponseObject.fromJson(json['lastMessage'] as Map<String, dynamic>),
$checkedConvert( json['status'] as String?,
'lastMessage', json['statusIcon'] as String?,
(v) => v == null json['statusMessage'] as String?,
? null
: GetChatResponseObject.fromJson(v as Map<String, dynamic>),
),
$checkedConvert('status', (v) => v as String?),
$checkedConvert('statusIcon', (v) => v as String?),
$checkedConvert('statusMessage', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$GetRoomResponseObjectToJson( Map<String, dynamic> _$GetRoomResponseObjectToJson(
GetRoomResponseObject instance, GetRoomResponseObject instance,
@@ -115,7 +93,7 @@ Map<String, dynamic> _$GetRoomResponseObjectToJson(
'unreadMentionDirect': instance.unreadMentionDirect, 'unreadMentionDirect': instance.unreadMentionDirect,
'lastReadMessage': instance.lastReadMessage, 'lastReadMessage': instance.lastReadMessage,
'lastCommonReadMessage': instance.lastCommonReadMessage, 'lastCommonReadMessage': instance.lastCommonReadMessage,
'lastMessage': instance.lastMessage?.toJson(), 'lastMessage': instance.lastMessage.toJson(),
'status': instance.status, 'status': instance.status,
'statusIcon': instance.statusIcon, 'statusIcon': instance.statusIcon,
'statusMessage': instance.statusMessage, 'statusMessage': instance.statusMessage,
@@ -128,7 +106,6 @@ const _$GetRoomResponseObjectConversationTypeEnumMap = {
GetRoomResponseObjectConversationType.changelog: 4, GetRoomResponseObjectConversationType.changelog: 4,
GetRoomResponseObjectConversationType.deleted: 5, GetRoomResponseObjectConversationType.deleted: 5,
GetRoomResponseObjectConversationType.noteToSelf: 6, GetRoomResponseObjectConversationType.noteToSelf: 6,
GetRoomResponseObjectConversationType.unknown: 'unknown',
}; };
const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = { const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = {
@@ -7,13 +7,10 @@ part of 'send_message_params.dart';
// ************************************************************************** // **************************************************************************
SendMessageParams _$SendMessageParamsFromJson(Map<String, dynamic> json) => SendMessageParams _$SendMessageParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('SendMessageParams', json, ($checkedConvert) { SendMessageParams(
final val = SendMessageParams( json['message'] as String,
$checkedConvert('message', (v) => v as String), replyTo: json['replyTo'] as String?,
replyTo: $checkedConvert('replyTo', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$SendMessageParamsToJson(SendMessageParams instance) => Map<String, dynamic> _$SendMessageParamsToJson(SendMessageParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,15 +7,9 @@ part of 'set_read_marker_params.dart';
// ************************************************************************** // **************************************************************************
SetReadMarkerParams _$SetReadMarkerParamsFromJson(Map<String, dynamic> json) => SetReadMarkerParams _$SetReadMarkerParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('SetReadMarkerParams', json, ($checkedConvert) { SetReadMarkerParams(
final val = SetReadMarkerParams( lastReadMessage: (json['lastReadMessage'] as num?)?.toInt(),
lastReadMessage: $checkedConvert(
'lastReadMessage',
(v) => (v as num?)?.toInt(),
),
); );
return val;
});
Map<String, dynamic> _$SetReadMarkerParamsToJson( Map<String, dynamic> _$SetReadMarkerParamsToJson(
SetReadMarkerParams instance, SetReadMarkerParams instance,
+4 -12
View File
@@ -8,8 +8,6 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/talk_exception.dart'; import '../../errors/talk_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../../json_lenient.dart';
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
import 'talk_error.dart'; import 'talk_error.dart';
@@ -25,12 +23,11 @@ abstract class TalkApi<T extends ApiResponse?> {
TalkApi(this.path, this.body, {this.headers, this.getParameters}); TalkApi(this.path, this.body, {this.headers, this.getParameters});
/// Defaults to a plain GET; endpoints with a body or another verb override it.
Future<http.Response>? request( Future<http.Response>? request(
Uri uri, Uri uri,
ApiParams? body, ApiParams? body,
Map<String, String>? headers, Map<String, String>? headers,
) => http.get(uri, headers: headers); );
T assemble(String raw); T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute` /// Override to parse large payloads off the UI isolate (e.g. via `compute`
@@ -75,17 +72,12 @@ abstract class TalkApi<T extends ApiResponse?> {
throwForStatus(status, detail); throwForStatus(status, detail);
} }
final T assembled;
try { try {
assembled = await assembleAsync(data.body); final assembled = await assembleAsync(data.body);
assembled?.headers = data.headers;
return assembled;
} catch (e) { } catch (e) {
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e'); throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
} }
assembled?.headers = data.headers;
ClientErrorReporter.reportDecodeWarnings('Talk $path', [
...?assembled?.parseWarnings,
...drainDecodeWarnings(),
]);
return assembled;
} }
} }
@@ -7,15 +7,11 @@ part of 'vote_poll_params.dart';
// ************************************************************************** // **************************************************************************
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) => VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('VotePollParams', json, ($checkedConvert) { VotePollParams(
final val = VotePollParams( optionIds: (json['optionIds'] as List<dynamic>)
optionIds: $checkedConvert( .map((e) => (e as num).toInt())
'optionIds', .toList(),
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
),
); );
return val;
});
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) => Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
<String, dynamic>{'optionIds': instance.optionIds}; <String, dynamic>{'optionIds': instance.optionIds};
@@ -7,28 +7,23 @@ part of 'cacheable_file.dart';
// ************************************************************************** // **************************************************************************
CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) => CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
$checkedCreate('CacheableFile', json, ($checkedConvert) { CacheableFile(
final val = CacheableFile( path: json['path'] as String,
path: $checkedConvert('path', (v) => v as String), isDirectory: json['isDirectory'] as bool,
isDirectory: $checkedConvert('isDirectory', (v) => v as bool), name: json['name'] as String,
name: $checkedConvert('name', (v) => v as String), mimeType: json['mimeType'] as String?,
mimeType: $checkedConvert('mimeType', (v) => v as String?), size: (json['size'] as num?)?.toInt(),
size: $checkedConvert('size', (v) => (v as num?)?.toInt()), eTag: json['eTag'] as String?,
eTag: $checkedConvert('eTag', (v) => v as String?), createdAt: json['createdAt'] == null
createdAt: $checkedConvert( ? null
'createdAt', : DateTime.parse(json['createdAt'] as String),
(v) => v == null ? null : DateTime.parse(v as String), modifiedAt: json['modifiedAt'] == null
), ? null
modifiedAt: $checkedConvert( : DateTime.parse(json['modifiedAt'] as String),
'modifiedAt', fileId: (json['fileId'] as num?)?.toInt(),
(v) => v == null ? null : DateTime.parse(v as String), hasPreview: json['hasPreview'] as bool?,
), isSharedWithMe: json['isSharedWithMe'] as bool?,
fileId: $checkedConvert('fileId', (v) => (v as num?)?.toInt()),
hasPreview: $checkedConvert('hasPreview', (v) => v as bool?),
isSharedWithMe: $checkedConvert('isSharedWithMe', (v) => v as bool?),
); );
return val;
});
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) => Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,10 +7,7 @@ part of 'list_files_params.dart';
// ************************************************************************** // **************************************************************************
ListFilesParams _$ListFilesParamsFromJson(Map<String, dynamic> json) => ListFilesParams _$ListFilesParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('ListFilesParams', json, ($checkedConvert) { ListFilesParams(json['path'] as String);
final val = ListFilesParams($checkedConvert('path', (v) => v as String));
return val;
});
Map<String, dynamic> _$ListFilesParamsToJson(ListFilesParams instance) => Map<String, dynamic> _$ListFilesParamsToJson(ListFilesParams instance) =>
<String, dynamic>{'path': instance.path}; <String, dynamic>{'path': instance.path};
@@ -7,23 +7,14 @@ part of 'list_files_response.dart';
// ************************************************************************** // **************************************************************************
ListFilesResponse _$ListFilesResponseFromJson(Map<String, dynamic> json) => ListFilesResponse _$ListFilesResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('ListFilesResponse', json, ($checkedConvert) { ListFilesResponse(
final val = ListFilesResponse( (json['files'] as List<dynamic>)
$checkedConvert(
'files',
(v) => (v as List<dynamic>)
.map((e) => CacheableFile.fromJson(e as Map<String, dynamic>)) .map((e) => CacheableFile.fromJson(e as Map<String, dynamic>))
.toSet(), .toSet(),
), )
); ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$ListFilesResponseToJson(ListFilesResponse instance) => Map<String, dynamic> _$ListFilesResponseToJson(ListFilesResponse instance) =>
<String, dynamic>{ <String, dynamic>{
+2 -12
View File
@@ -3,8 +3,6 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart'; import '../../../session/session_manager.dart';
import '../../api_response.dart'; import '../../api_response.dart';
import '../../demo/demo_mode.dart';
import '../../errors/network_exception.dart';
abstract class WebdavApi<T> { abstract class WebdavApi<T> {
T genericParams; T genericParams;
@@ -20,14 +18,6 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps /// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials. /// authenticating with stale credentials.
static Future<WebDavClient> get webdav { static Future<WebDavClient> get webdav {
// The WebDAV client talks dart:io directly, past DemoHttpClient.
if (DemoMode.active) {
return Future.error(
NetworkException.demoBlocked(
Uri.parse(EndpointData().nextcloud().origin()),
),
);
}
// Keyed by user too: two accounts may share a password (no app password // Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name. // minted), and the client would keep the previous login name.
final nextcloud = SessionManager().requireNextcloud(); final nextcloud = SessionManager().requireNextcloud();
@@ -41,7 +31,7 @@ abstract class WebdavApi<T> {
static Future<WebDavClient> establishWebdavConnection() async => static Future<WebDavClient> establishWebdavConnection() async =>
NextcloudClient( NextcloudClient(
Uri.parse(EndpointData().nextcloud().origin()), Uri.parse('https://${EndpointData().nextcloud().full()}'),
// App password preferred — with 2FA the real password is not accepted // App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow). // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
password: SessionManager().requireNextcloud().secret, password: SessionManager().requireNextcloud().secret,
@@ -51,5 +41,5 @@ abstract class WebdavApi<T> {
/// Builds the WebDAV download URL without embedded credentials. Callers must /// Builds the WebDAV download URL without embedded credentials. Callers must
/// authenticate via the [NextcloudCredentials.authHeaders] header instead. /// authenticate via the [NextcloudCredentials.authHeaders] header instead.
static String buildWebdavUrl() => static String buildWebdavUrl() =>
'${EndpointData().nextcloud().origin()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/'; 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
} }
@@ -7,7 +7,8 @@ import 'device_token_name.dart';
import 'token_storage.dart'; import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, /// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// renews the token once before retrying. /// renews the token once before retrying. Only password accounts can renew
/// silently; passwordless accounts surface the 401.
class MarianumConnectAuthInterceptor extends Interceptor { class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried'; static const _retriedKey = 'mc_auth_retried';
@@ -74,6 +75,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
} }
final refreshed = await _attemptReLogin(); final refreshed = await _attemptReLogin();
if (!refreshed) { if (!refreshed) {
if (SessionManager().current is GuardianSession) {
SessionManager().reportUnauthorized();
}
handler.next(err); handler.next(err);
return; return;
} }
@@ -4,11 +4,13 @@ import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart'; import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart'; import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart'; import '../../errors/auth_exception.dart';
import '../queries/auth_me/auth_me.dart';
import '../queries/auth_verify/auth_verify.dart'; import '../queries/auth_verify/auth_verify.dart';
/// Credential probe. A server-side password rotation forces a re-login on the /// Credential probe. For password accounts a server-side password rotation
/// next cold start even when the bearer token would still be accepted. /// forces a re-login on the next cold start even when the bearer token would
/// Another stored account then takes over. /// still be accepted; for guardians it confirms a rejected token before the
/// session is dropped. Another stored account then takes over.
class SessionValidator { class SessionValidator {
static Future<void> probeStored({ static Future<void> probeStored({
required Future<void> Function(String? nextAccountId) onInvalidated, required Future<void> Function(String? nextAccountId) onInvalidated,
@@ -22,6 +24,8 @@ class SessionValidator {
switch (session) { switch (session) {
case CredentialSession(:final username, :final password): case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password); await AuthVerify().run(username: username, password: password);
case GuardianSession():
await AuthMe().run();
} }
} on AuthException catch (e) { } on AuthException catch (e) {
if (e.statusCode != 401) return; if (e.statusCode != 401) return;
@@ -34,6 +38,8 @@ class SessionValidator {
CredentialSession() => CredentialSession() =>
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich ' 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
'wurde dein Passwort geändert. Bitte melde dich erneut an.', 'wurde dein Passwort geändert. Bitte melde dich erneut an.',
GuardianSession() =>
'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.',
}, },
); );
await onInvalidated(next); await onInvalidated(next);
@@ -1,7 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../storage/keychain_write.dart';
import '../../errors/auth_exception.dart'; import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background /// `first_unlock` accessibility so the token can be read during background
@@ -53,9 +52,9 @@ class MarianumConnectTokenStorage {
required String tokenId, required String tokenId,
required DateTime? expiresAt, required DateTime? expiresAt,
}) async { }) async {
await _storage.writeRetrying(key: _tokenKey, value: token); await _storage.write(key: _tokenKey, value: token);
await _storage.writeRetrying(key: _tokenIdKey, value: tokenId); await _storage.write(key: _tokenIdKey, value: tokenId);
await _storage.writeRetrying( await _storage.write(
key: _expiresAtKey, key: _expiresAtKey,
value: expiresAt?.toIso8601String() ?? '', value: expiresAt?.toIso8601String() ?? '',
); );
@@ -76,14 +75,14 @@ class MarianumConnectTokenStorage {
if (value == null) { if (value == null) {
await _storage.delete(key: key); await _storage.delete(key: key);
} else { } else {
await _storage.writeRetrying(key: key, value: value); await _storage.write(key: key, value: value);
} }
} }
} }
Future<void> clear() async { Future<void> clear() async {
for (final key in fieldKeys) { await _storage.delete(key: _tokenKey);
await _storage.delete(key: key); await _storage.delete(key: _tokenIdKey);
} await _storage.delete(key: _expiresAtKey);
} }
} }
@@ -1,8 +0,0 @@
import 'dart:io';
/// Platform wire value MarianumConnect expects (`android`/`ios`), null elsewhere.
String? clientPlatform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of /// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites /// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message. /// that just want to render an error message.
@@ -6,22 +6,12 @@ part of 'mc_holiday.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
McHoliday _$McHolidayFromJson(Map<String, dynamic> json) => McHoliday _$McHolidayFromJson(Map<String, dynamic> json) => McHoliday(
$checkedCreate('McHoliday', json, ($checkedConvert) { shortName: json['shortName'] as String,
final val = McHoliday( longName: json['longName'] as String,
shortName: $checkedConvert('shortName', (v) => v as String), startDate: McHoliday._dateFromJson(json['startDate'] as String),
longName: $checkedConvert('longName', (v) => v as String), endDate: McHoliday._dateFromJson(json['endDate'] as String),
startDate: $checkedConvert(
'startDate',
(v) => McHoliday._dateFromJson(v as String),
),
endDate: $checkedConvert(
'endDate',
(v) => McHoliday._dateFromJson(v as String),
),
); );
return val;
});
Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{ Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{
'shortName': instance.shortName, 'shortName': instance.shortName,
@@ -1,10 +1,30 @@
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'absence_prefill_response.dart'; import 'absence_prefill_response.dart';
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth). /// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
/// Guardians pass the [childId] the report is for; the identity then comes
/// from that child.
class AbsencePrefill extends MarianumConnectQuery { class AbsencePrefill extends MarianumConnectQuery {
AbsencePrefill({super.dio}); AbsencePrefill({super.dio});
Future<AbsencePrefillResponse> run() => Future<AbsencePrefillResponse> run({String? childId}) => getObject(
getObject('absence/prefill', AbsencePrefillResponse.fromJson); 'absence/prefill',
AbsencePrefillResponse.fromJson,
queryParameters: {'childId': ?childId},
);
}
/// The prefill of a guardian's report is missing a field the form cannot ask
/// for: name and class come from the child and are not editable there, so an
/// incomplete prefill would leave a locked, unsubmittable form behind.
class AbsencePrefillIncompleteException extends AppException {
const AbsencePrefillIncompleteException({super.technicalDetails})
: super(
userMessage:
'Für dieses Kind sind in der Schulverwaltung noch nicht alle '
'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. '
'Bitte wende dich an das Sekretariat.',
allowRetry: false,
);
} }
@@ -8,15 +8,12 @@ part of 'absence_prefill_response.dart';
AbsencePrefillResponse _$AbsencePrefillResponseFromJson( AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('AbsencePrefillResponse', json, ($checkedConvert) { ) => AbsencePrefillResponse(
final val = AbsencePrefillResponse( firstName: json['firstName'] as String? ?? '',
firstName: $checkedConvert('firstName', (v) => v as String? ?? ''), lastName: json['lastName'] as String? ?? '',
lastName: $checkedConvert('lastName', (v) => v as String? ?? ''), className: json['className'] as String? ?? '',
className: $checkedConvert('className', (v) => v as String? ?? ''), phone: json['phone'] as String? ?? '',
phone: $checkedConvert('phone', (v) => v as String? ?? ''),
); );
return val;
});
Map<String, dynamic> _$AbsencePrefillResponseToJson( Map<String, dynamic> _$AbsencePrefillResponseToJson(
AbsencePrefillResponse instance, AbsencePrefillResponse instance,
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`). /// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
/// Empty identity fields are backfilled from LDAP server-side; validation /// Empty identity fields are backfilled from LDAP server-side; validation
/// (all fields required, class must exist, no past start date, end >= start) /// (all fields required, class must exist, no past start date, end >= start)
/// also runs server-side and mirrors the client checks. /// also runs server-side and mirrors the client checks. For guardians the
/// server takes name and class from the child given by [childId].
class AbsenceSubmit extends MarianumConnectQuery { class AbsenceSubmit extends MarianumConnectQuery {
AbsenceSubmit({super.dio}); AbsenceSubmit({super.dio});
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
required DateTime absentUntil, required DateTime absentUntil,
required String phone, required String phone,
required String note, required String note,
String? childId,
}) => guard(() async { }) => guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('absence'), endpoint('absence'),
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
'absentUntil': isoDate(absentUntil), 'absentUntil': isoDate(absentUntil),
'phone': phone, 'phone': phone,
'note': note, 'note': note,
'childId': ?childId,
}, },
); );
}); });
@@ -0,0 +1,60 @@
import 'package:dio/dio.dart';
import '../../../../auth_link/pending_guardian_request.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'guardian_login_exception.dart';
class AuthGuardianRequestResponse {
final String requestId;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const AuthGuardianRequestResponse({
required this.requestId,
required this.expiresAt,
required this.resendAvailableAt,
required this.codeLength,
});
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
AuthGuardianRequestResponse(
requestId: json['requestId'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength:
json['codeLength'] as int? ??
PendingGuardianRequest.defaultCodeLength,
);
}
/// Starts a passwordless guardian login: the server mails a code and a link.
/// An unknown address is reported as such (`email_not_registered`) instead of
/// being answered like a known one — a deliberate trade: without it guardians
/// wait for a mail that never arrives. Registered addresses are therefore
/// enumerable; keep that in mind when changing the server contract.
class AuthGuardianRequest extends MarianumConnectQuery {
AuthGuardianRequest({Dio? dio})
: super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthGuardianRequestResponse> run({
required String email,
required String deviceChallenge,
required String tokenName,
}) async {
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/request'),
data: {
'email': email,
'deviceChallenge': deviceChallenge,
'tokenName': tokenName,
},
);
return AuthGuardianRequestResponse.fromJson(response.data!);
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e, verifying: false);
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
import 'guardian_login_exception.dart';
/// Completes a guardian login with the mailed code or link token and stores
/// the issued bearer token.
class AuthGuardianVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthGuardianVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String requestId,
required String deviceVerifier,
required String tokenName,
String? code,
String? linkToken,
}) async {
assert((code == null) != (linkToken == null));
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/verify'),
data: {
'requestId': requestId,
'deviceVerifier': deviceVerifier,
'tokenName': tokenName,
'code': ?code,
'linkToken': ?linkToken,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,120 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum GuardianLoginError {
invalidRequest,
emailNotRegistered,
accountDisabled,
invalidCode,
deviceMismatch,
requestConsumed,
requestExpired,
tooManyAttempts,
rateLimited,
unsupportedServer,
}
/// A rejected guardian login step with the reason the server reported.
class GuardianLoginException extends AppException {
final GuardianLoginError error;
final int? attemptsLeft;
const GuardianLoginException(
this.error, {
required super.userMessage,
this.attemptsLeft,
super.technicalDetails,
}) : super(allowRetry: false);
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
/// login reason become [GuardianLoginException]; everything else keeps the
/// generic MarianumConnect mapping (network, 5xx, …).
///
/// [verifying] false marks the e-mail step, where no code exists yet: a bare
/// 401 (reverse proxy, gateway) must not be reported as a wrong code there.
static AppException fromDio(DioException e, {bool verifying = true}) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500) {
return mapMarianumConnectError(e);
}
final (code, attemptsLeft) = _parseBody(response!.data);
final error = _errorFor(code, status, verifying: verifying);
if (error == null) return mapMarianumConnectError(e);
return GuardianLoginException(
error,
attemptsLeft: attemptsLeft,
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(
GuardianLoginError error, {
int? attemptsLeft,
}) => switch (error) {
GuardianLoginError.invalidRequest =>
'Bitte gib eine gültige E-Mail-Adresse ein.',
GuardianLoginError.emailNotRegistered =>
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
GuardianLoginError.accountDisabled =>
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
'das Sekretariat.',
GuardianLoginError.invalidCode =>
attemptsLeft == null
? 'Der Code ist falsch.'
: 'Der Code ist falsch. Noch $attemptsLeft '
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
GuardianLoginError.deviceMismatch =>
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
'Bitte gib stattdessen den Code aus der E-Mail ein.',
GuardianLoginError.requestConsumed =>
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
'Code an.',
GuardianLoginError.requestExpired =>
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
GuardianLoginError.tooManyAttempts =>
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
GuardianLoginError.rateLimited =>
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
'erneut.',
GuardianLoginError.unsupportedServer =>
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
'Bitte versuche es später erneut.',
};
static (String?, int?) _parseBody(Object? data) {
final attempts = data is Map ? data['attemptsLeft'] : null;
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
}
static GuardianLoginError? _errorFor(
String? code,
int status, {
required bool verifying,
}) => switch (code) {
'invalid_request' => GuardianLoginError.invalidRequest,
'email_not_registered' => GuardianLoginError.emailNotRegistered,
'account_disabled' => GuardianLoginError.accountDisabled,
'invalid_code' => GuardianLoginError.invalidCode,
'device_mismatch' => GuardianLoginError.deviceMismatch,
'request_consumed' => GuardianLoginError.requestConsumed,
'request_expired' => GuardianLoginError.requestExpired,
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
_ => switch (status) {
401 when verifying => GuardianLoginError.invalidCode,
// The server names an unknown address explicitly
// (`email_not_registered`); a bare 404/405 means the endpoint itself
// is missing, i.e. a server version without guardian login.
404 || 405 => GuardianLoginError.unsupportedServer,
409 => GuardianLoginError.requestConsumed,
410 => GuardianLoginError.requestExpired,
429 => GuardianLoginError.rateLimited,
_ => null,
},
};
}
@@ -7,17 +7,14 @@ part of 'auth_login_response.dart';
// ************************************************************************** // **************************************************************************
AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) => AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) =>
$checkedCreate('AuthLoginUser', json, ($checkedConvert) { AuthLoginUser(
final val = AuthLoginUser( id: json['id'] as String,
id: $checkedConvert('id', (v) => v as String), username: json['username'] as String,
username: $checkedConvert('username', (v) => v as String), firstName: json['firstName'] as String,
firstName: $checkedConvert('firstName', (v) => v as String), lastName: json['lastName'] as String,
lastName: $checkedConvert('lastName', (v) => v as String), userType: json['userType'] as String?,
userType: $checkedConvert('userType', (v) => v as String?), className: json['className'] as String?,
className: $checkedConvert('className', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) => Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -30,21 +27,12 @@ Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
}; };
AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) => AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('AuthLoginResponse', json, ($checkedConvert) { AuthLoginResponse(
final val = AuthLoginResponse( token: json['token'] as String,
token: $checkedConvert('token', (v) => v as String), tokenId: json['tokenId'] as String,
tokenId: $checkedConvert('tokenId', (v) => v as String), expiresAt: AuthLoginResponse._expiresFromJson(json['expiresAt']),
expiresAt: $checkedConvert( user: AuthLoginUser.fromJson(json['user'] as Map<String, dynamic>),
'expiresAt',
(v) => AuthLoginResponse._expiresFromJson(v),
),
user: $checkedConvert(
'user',
(v) => AuthLoginUser.fromJson(v as Map<String, dynamic>),
),
); );
return val;
});
Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) => Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -6,8 +6,8 @@ import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart'; import '../auth_login/auth_login_response.dart';
/// Reads the user behind the stored bearer token, which also probes that the /// Probes that the stored bearer token is still accepted. Used for accounts
/// token is still accepted. /// without a password (guardians), whose token cannot be renewed silently.
/// ///
/// Bypasses the shared dio singleton so the auth interceptor does not react /// Bypasses the shared dio singleton so the auth interceptor does not react
/// to the 401 this probe is meant to observe. /// to the 401 this probe is meant to observe.
@@ -25,7 +25,6 @@ class GetBreakersResponse extends ApiResponse {
@JsonSerializable() @JsonSerializable()
class BreakerRule { class BreakerRule {
@JsonKey(unknownEnumValue: BreakerArea.unknown)
List<BreakerArea> areas; List<BreakerArea> areas;
String message; String message;
@@ -70,7 +69,4 @@ enum BreakerArea {
feedback, feedback,
@JsonValue('MORE') @JsonValue('MORE')
more, more,
/// An area the backend added after this build; no module maps to it.
unknown,
} }
@@ -7,25 +7,15 @@ part of 'get_breakers_response.dart';
// ************************************************************************** // **************************************************************************
GetBreakersResponse _$GetBreakersResponseFromJson(Map<String, dynamic> json) => GetBreakersResponse _$GetBreakersResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetBreakersResponse', json, ($checkedConvert) { GetBreakersResponse(
final val = GetBreakersResponse( (json['rules'] as List<dynamic>?)
$checkedConvert(
'rules',
(v) =>
(v as List<dynamic>?)
?.map((e) => BreakerRule.fromJson(e as Map<String, dynamic>)) ?.map((e) => BreakerRule.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[], [],
), )
); ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetBreakersResponseToJson( Map<String, dynamic> _$GetBreakersResponseToJson(
GetBreakersResponse instance, GetBreakersResponse instance,
@@ -34,26 +24,13 @@ Map<String, dynamic> _$GetBreakersResponseToJson(
'rules': instance.rules.map((e) => e.toJson()).toList(), 'rules': instance.rules.map((e) => e.toJson()).toList(),
}; };
BreakerRule _$BreakerRuleFromJson(Map<String, dynamic> json) => BreakerRule _$BreakerRuleFromJson(Map<String, dynamic> json) => BreakerRule(
$checkedCreate('BreakerRule', json, ($checkedConvert) { (json['areas'] as List<dynamic>)
final val = BreakerRule( .map((e) => $enumDecode(_$BreakerAreaEnumMap, e))
$checkedConvert(
'areas',
(v) => (v as List<dynamic>)
.map(
(e) => $enumDecode(
_$BreakerAreaEnumMap,
e,
unknownValue: BreakerArea.unknown,
),
)
.toList(), .toList(),
), json['message'] as String,
$checkedConvert('message', (v) => v as String), (json['maxBuild'] as num?)?.toInt(),
$checkedConvert('maxBuild', (v) => (v as num?)?.toInt()),
); );
return val;
});
Map<String, dynamic> _$BreakerRuleToJson(BreakerRule instance) => Map<String, dynamic> _$BreakerRuleToJson(BreakerRule instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -75,5 +52,4 @@ const _$BreakerAreaEnumMap = {
BreakerArea.dates: 'DATES', BreakerArea.dates: 'DATES',
BreakerArea.feedback: 'FEEDBACK', BreakerArea.feedback: 'FEEDBACK',
BreakerArea.more: 'MORE', BreakerArea.more: 'MORE',
BreakerArea.unknown: 'unknown',
}; };
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'guardian_child.dart';
part 'get_capabilities_response.g.dart'; part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend /// Slimmed-down capability flags the mobile UI gates features on. The backend
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
final int? timetableFutureDays; final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend /// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
/// predates the field or has no LDAP record for the user. /// backend predates the field or has no record for the user.
final String? userType; final String? userType;
/// Students linked to a guardian account; empty for everyone else.
@JsonKey(defaultValue: <GuardianChild>[])
final List<GuardianChild> children;
CapabilitiesResponse({ CapabilitiesResponse({
required this.viewForeignTimetables, required this.viewForeignTimetables,
required this.pushNotifications, required this.pushNotifications,
this.timetablePastDays, this.timetablePastDays,
this.timetableFutureDays, this.timetableFutureDays,
this.userType, this.userType,
this.children = const [],
}); });
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) => factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -8,28 +8,18 @@ part of 'get_capabilities_response.dart';
CapabilitiesResponse _$CapabilitiesResponseFromJson( CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('CapabilitiesResponse', json, ($checkedConvert) { ) => CapabilitiesResponse(
final val = CapabilitiesResponse( viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
viewForeignTimetables: $checkedConvert( pushNotifications: json['pushNotifications'] as bool? ?? false,
'viewForeignTimetables', timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
(v) => v as bool? ?? false, timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
), userType: json['userType'] as String?,
pushNotifications: $checkedConvert( children:
'pushNotifications', (json['children'] as List<dynamic>?)
(v) => v as bool? ?? false, ?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
), .toList() ??
timetablePastDays: $checkedConvert( [],
'timetablePastDays',
(v) => (v as num?)?.toInt(),
),
timetableFutureDays: $checkedConvert(
'timetableFutureDays',
(v) => (v as num?)?.toInt(),
),
userType: $checkedConvert('userType', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
CapabilitiesResponse instance, CapabilitiesResponse instance,
@@ -39,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
'timetablePastDays': instance.timetablePastDays, 'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays, 'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType, 'userType': instance.userType,
'children': instance.children,
}; };
@@ -0,0 +1,27 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'guardian_child.freezed.dart';
part 'guardian_child.g.dart';
/// A student linked to the signed-in guardian. [id] is an opaque server id,
/// not a WebUntis id.
@freezed
abstract class GuardianChild with _$GuardianChild {
const GuardianChild._();
const factory GuardianChild({
required String id,
required String firstName,
required String lastName,
@Default('') String className,
/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
String? username,
}) = _GuardianChild;
factory GuardianChild.fromJson(Map<String, Object?> json) =>
_$GuardianChildFromJson(json);
String get displayName => '$firstName $lastName'.trim();
}
@@ -0,0 +1,301 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'guardian_child.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GuardianChild {
String get id; String get firstName; String get lastName; String get className;/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
String? get username;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
/// Serializes this GuardianChild to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as GuardianChild;
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className)&&(identical(other.username, _this.username) || other.username == _this.username));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as GuardianChild;
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className,_this.username);
}
@override
String toString() {
final _this = this as GuardianChild;
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className}, username: ${_this.username})';
}
}
/// @nodoc
abstract mixin class $GuardianChildCopyWith<$Res> {
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
@useResult
$Res call({
String id, String firstName, String lastName, String className, String? username
});
}
/// @nodoc
class _$GuardianChildCopyWithImpl<$Res>
implements $GuardianChildCopyWith<$Res> {
_$GuardianChildCopyWithImpl(this._self, this._then);
final GuardianChild _self;
final $Res Function(GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
return _then(GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [GuardianChild].
extension GuardianChildPatterns on GuardianChild {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GuardianChild value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GuardianChild value) $default,){
final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GuardianChild value)? $default,){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username) $default,) {final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className, String? username)? $default,) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _GuardianChild extends GuardianChild {
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = '', this.username}): super._();
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
@override final String id;
@override final String firstName;
@override final String lastName;
@override@JsonKey() final String className;
/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
@override final String? username;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$GuardianChildToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className)&&(identical(other.username, username) || other.username == username));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,id,firstName,lastName,className,username);
}
@override
String toString() {
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className, username: $username)';
}
}
/// @nodoc
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
@override @useResult
$Res call({
String id, String firstName, String lastName, String className, String? username
});
}
/// @nodoc
class __$GuardianChildCopyWithImpl<$Res>
implements _$GuardianChildCopyWith<$Res> {
__$GuardianChildCopyWithImpl(this._self, this._then);
final _GuardianChild _self;
final $Res Function(_GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
return _then(_GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'guardian_child.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
_GuardianChild(
id: json['id'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
className: json['className'] as String? ?? '',
username: json['username'] as String?,
);
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
'username': instance.username,
};
@@ -7,20 +7,14 @@ part of 'get_ticker_response.dart';
// ************************************************************************** // **************************************************************************
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) => TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('TickerResponse', json, ($checkedConvert) { TickerResponse(
final val = TickerResponse( schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
schemaVersion: $checkedConvert( available: json['available'] as bool? ?? false,
'schemaVersion', hash: json['hash'] as String?,
(v) => (v as num?)?.toInt() ?? 1, publishedAt: json['publishedAt'] as String?,
), webUrl: json['webUrl'] as String? ?? '/ticker',
available: $checkedConvert('available', (v) => v as bool? ?? false), content: json['content'] as Map<String, dynamic>?,
hash: $checkedConvert('hash', (v) => v as String?),
publishedAt: $checkedConvert('publishedAt', (v) => v as String?),
webUrl: $checkedConvert('webUrl', (v) => v as String? ?? '/ticker'),
content: $checkedConvert('content', (v) => v as Map<String, dynamic>?),
); );
return val;
});
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) => Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,26 +7,15 @@ part of 'get_ticker_nav_response.dart';
// ************************************************************************** // **************************************************************************
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) => TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('TickerNavResponse', json, ($checkedConvert) { TickerNavResponse(
final val = TickerNavResponse( schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
schemaVersion: $checkedConvert( navHash: json['navHash'] as String? ?? '',
'schemaVersion', sections:
(v) => (v as num?)?.toInt() ?? 1, (json['sections'] as List<dynamic>?)
), ?.map((e) => TickerNavSection.fromJson(e as Map<String, dynamic>))
navHash: $checkedConvert('navHash', (v) => v as String? ?? ''),
sections: $checkedConvert(
'sections',
(v) =>
(v as List<dynamic>?)
?.map(
(e) => TickerNavSection.fromJson(e as Map<String, dynamic>),
)
.toList() ?? .toList() ??
[], [],
),
); );
return val;
});
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) => Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -36,22 +25,14 @@ Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
}; };
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) => TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
$checkedCreate('TickerNavSection', json, ($checkedConvert) { TickerNavSection(
final val = TickerNavSection( title: json['title'] as String? ?? '',
title: $checkedConvert('title', (v) => v as String? ?? ''), pages:
pages: $checkedConvert( (json['pages'] as List<dynamic>?)
'pages', ?.map((e) => TickerNavPage.fromJson(e as Map<String, dynamic>))
(v) =>
(v as List<dynamic>?)
?.map(
(e) => TickerNavPage.fromJson(e as Map<String, dynamic>),
)
.toList() ?? .toList() ??
[], [],
),
); );
return val;
});
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) => Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -60,21 +41,15 @@ Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
}; };
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) => TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
$checkedCreate('TickerNavPage', json, ($checkedConvert) { TickerNavPage(
final val = TickerNavPage( title: json['title'] as String? ?? '',
title: $checkedConvert('title', (v) => v as String? ?? ''), slug: json['slug'] as String? ?? '',
slug: $checkedConvert('slug', (v) => v as String? ?? ''), kind: json['kind'] as String? ?? 'CONTENT',
kind: $checkedConvert('kind', (v) => v as String? ?? 'CONTENT'), hash: json['hash'] as String?,
hash: $checkedConvert('hash', (v) => v as String?), externalUrl: json['externalUrl'] as String?,
externalUrl: $checkedConvert('externalUrl', (v) => v as String?), externalUrlNewTab: json['externalUrlNewTab'] as bool?,
externalUrlNewTab: $checkedConvert( fileUrl: json['fileUrl'] as String?,
'externalUrlNewTab',
(v) => v as bool?,
),
fileUrl: $checkedConvert('fileUrl', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) => Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../../errors/ticker_content_unavailable_exception.dart'; import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'get_ticker_page_response.dart'; import 'get_ticker_page_response.dart';
@@ -14,20 +14,26 @@ import 'get_ticker_page_response.dart';
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped /// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser /// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a /// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error. /// generic error. The bespoke 404 handling is why this keeps its own try/catch
/// instead of the base [guard].
class GetTickerPage extends MarianumConnectQuery { class GetTickerPage extends MarianumConnectQuery {
final String slug; final String slug;
GetTickerPage(this.slug, {super.dio}); GetTickerPage(this.slug, {super.dio});
Future<TickerPageResponse> run() => getObject( Future<TickerPageResponse> run() async {
'ticker/pages/${Uri.encodeComponent(slug)}', try {
TickerPageResponse.fromJson, 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);
}
}
@override Object _mapError(DioException e) {
AppException mapError(DioException error) { final response = e.response;
final response = error.response;
if (response?.statusCode == 404) { if (response?.statusCode == 404) {
final body = _asMap(response?.data); final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') { if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
@@ -38,7 +44,7 @@ class GetTickerPage extends MarianumConnectQuery {
); );
} }
} }
return super.mapError(error); return mapMarianumConnectError(e);
} }
Map<String, dynamic>? _asMap(dynamic data) { Map<String, dynamic>? _asMap(dynamic data) {
@@ -7,31 +7,22 @@ part of 'get_ticker_page_response.dart';
// ************************************************************************** // **************************************************************************
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) => TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('TickerPageResponse', json, ($checkedConvert) { TickerPageResponse(
final val = TickerPageResponse( schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
schemaVersion: $checkedConvert( slug: json['slug'] as String?,
'schemaVersion', title: json['title'] as String?,
(v) => (v as num?)?.toInt() ?? 1, kind: json['kind'] as String? ?? 'CONTENT',
), content: json['content'] as Map<String, dynamic>?,
slug: $checkedConvert('slug', (v) => v as String?), externalUrl: json['externalUrl'] as String?,
title: $checkedConvert('title', (v) => v as String?), externalUrlNewTab: json['externalUrlNewTab'] as bool?,
kind: $checkedConvert('kind', (v) => v as String? ?? 'CONTENT'), fileUrl: json['fileUrl'] as String?,
content: $checkedConvert('content', (v) => v as Map<String, dynamic>?), contentType: json['contentType'] as String?,
externalUrl: $checkedConvert('externalUrl', (v) => v as String?), filename: json['filename'] as String?,
externalUrlNewTab: $checkedConvert( hash: json['hash'] as String?,
'externalUrlNewTab', webUrl: json['webUrl'] as String?,
(v) => v as bool?, publishedAt: json['publishedAt'] as String?,
), fileFetchedAt: json['fileFetchedAt'] as String?,
fileUrl: $checkedConvert('fileUrl', (v) => v as String?),
contentType: $checkedConvert('contentType', (v) => v as String?),
filename: $checkedConvert('filename', (v) => v as String?),
hash: $checkedConvert('hash', (v) => v as String?),
webUrl: $checkedConvert('webUrl', (v) => v as String?),
publishedAt: $checkedConvert('publishedAt', (v) => v as String?),
fileFetchedAt: $checkedConvert('fileFetchedAt', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) => Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
<String, dynamic>{ <String, dynamic>{
Loaded 100 of 460 files, more files were not shown because too many files have changed in this diff. Show more