Author SHA1 Message Date
MineTecandClaude Opus 5.5 5a8fed24bb added ios privacy manifests, fixed permission texts and updated the xcode project
Privacy manifests for the app, widget and share extension declare the
App Group UserDefaults access (CA92.1, 1C8F.1) required for App Store
submission. Photo library and camera usage texts corrected. Project
format and SwiftPM pins (Firebase 12.19) as updated by Xcode 27 / Flutter
3.47.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:51:28 +02:00
MineTecandClaude Opus 5.5 b413fe05de fixed timetable ruler and day header overflowing at large system text sizes
Both have fixed geometry, so their text scaling is capped at 1.3. Ruler
times shrink instead of wrapping into the period number and are only
shown when they fit at the current scale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:51:28 +02:00
MineTecandClaude Opus 5.5 38a651f916 fixed missing period times in the ios widget ruler and unreadable tiles on tinted and clear home screens
A .systemLarge widget only gets ~27pt per period, below the 30pt threshold
shared with Android, so iOS now shows the times from 24pt with a tighter
cell. In non-full-color rendering modes the opaque tiles and the today
circle flattened into solid slabs hiding their text; they are drawn
translucent with an outline there and use the light-text palette.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:51:28 +02:00
MineTec f98a6979fb merged develop 2026-09-27 14:58:07 +02:00
MineTec a71f4f379b ios package-update 2026-09-25 11:02:01 +02:00
MineTec 84098af7e2 added support for multiple accounts, guardian login bugfixes, ui changes 2026-09-23 20:50:12 +02:00
MineTec 630497abdd fixed guardian login edge cases and misleading placeholders on failed loads 2026-09-20 19:03:45 +02:00
MineTec 2423c1a75e added guardian letters with chat and multiple answer functionalities 2026-09-20 16:12:45 +02:00
MineTec 67c935c05b added guardian login with views for their assigned childs 2026-09-20 11:11:58 +02:00
320 changed files with 15218 additions and 3199 deletions

No files matched your search

+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 -->
+3 -1
View File
@@ -106,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',
+2 -8
View File
@@ -12,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
@@ -21,7 +19,6 @@ DEPENDENCIES:
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`) - flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6 PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0
+9 -6
View File
@@ -3,11 +3,12 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; }; 034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; };
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; }; 33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
@@ -77,19 +78,19 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; }; 12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; 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; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.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; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; }; 509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; }; 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.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>"; };
@@ -107,6 +108,7 @@
AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; }; BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; }; BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; };
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; }; BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; };
@@ -222,7 +224,6 @@
12B96C78930441C73F123636 /* Info.plist */, 12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */, 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
); );
name = NotificationServiceExtension;
path = NotificationServiceExtension; path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT; sourceTree = SOURCE_ROOT;
}; };
@@ -304,6 +305,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
AA0102020000000022222222 /* SceneDelegate.swift */, AA0102020000000022222222 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -469,6 +471,7 @@
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */, 33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -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" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.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" : "dc39082d8881109d35b94b1c122164c0e8d08a55", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.1" "version" : "3.7.0"
} }
}, },
{ {
@@ -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" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -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" : "9f183ae842be978784f2963a343682e0c46d8fb3", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.2" "version" : "8.1.3"
} }
}, },
{ {
@@ -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" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.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" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.0" "version" : "3.7.0"
} }
}, },
{ {
@@ -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" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -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" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.1" "version" : "8.1.3"
} }
}, },
{ {
+4 -2
View File
@@ -37,12 +37,14 @@
</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>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können, wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string> <string>Um Fotos und Videos mit anderen zu teilen, wird Zugriff auf deine Mediathek benötigt.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+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>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -147,7 +147,8 @@ struct PeriodRuler: View {
.font(.system(size: timeFont)) .font(.system(size: timeFont))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.padding(.vertical, 2) .minimumScaleFactor(0.8)
.padding(.vertical, 1)
} }
} }
.frame(width: RULER_WIDTH, height: height) .frame(width: RULER_WIDTH, height: height)
@@ -304,6 +305,8 @@ private struct LessonTile: View {
let metrics: TileMetrics let metrics: TileMetrics
let layout: TileTextLayout let layout: TileTextLayout
@Environment(\.widgetRenderingMode) private var renderingMode
private static let cancelledRed = private static let cancelledRed =
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255) Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
@@ -326,7 +329,7 @@ private struct LessonTile: View {
.padding(.vertical, TILE_PADDING_V) .padding(.vertical, TILE_PADDING_V)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading) .frame(height: height, alignment: .topLeading)
.background(lesson.tileColor) .background { tileBackground(shape) }
.clipShape(shape) .clipShape(shape)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if let count = lesson.siblingCount, count > 0 { if let count = lesson.siblingCount, count > 0 {
@@ -357,6 +360,22 @@ private struct LessonTile: View {
.padding(.horizontal, TILE_MARGIN_H) .padding(.horizontal, TILE_MARGIN_H)
} }
/// Tinted / clear home screens flatten an opaque colour block into a
/// solid slab the white text disappears on; a translucent fill with an
/// outline keeps tile and text apart in every rendering mode.
@ViewBuilder
private func tileBackground(_ shape: RoundedRectangle) -> some View {
if renderingMode == .fullColor {
lesson.tileColor
} else {
ZStack {
shape.fill(Color.white.opacity(0.18))
shape.strokeBorder(Color.white.opacity(0.55), lineWidth: 1)
}
.widgetAccentable()
}
}
private var titleRow: some View { private var titleRow: some View {
HStack(alignment: .center, spacing: 3) { HStack(alignment: .center, spacing: 3) {
Text(layout.title) Text(layout.title)
@@ -47,7 +47,10 @@ let TITLE_WRAP_BELOW_FONT: CGFloat = 8
let BADGE_WIDTH: CGFloat = 16 let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11 let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30 /// Lower than Android's 30dp: a .systemLarge widget only gets ~27pt per
/// period, so iOS packs the ruler cell tighter (see PeriodRuler.cell)
/// instead of dropping the times.
let RULER_TIMES_MIN_CELL: CGFloat = 24
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12 let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for /// Space the holiday column keeps free above and below the rotated name for
@@ -4,6 +4,7 @@ import WidgetKit
struct TimetableWeekView: View { struct TimetableWeekView: View {
let entry: TimetableEntry let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
@Environment(\.widgetRenderingMode) private var renderingMode
var body: some View { var body: some View {
ZStack { ZStack {
@@ -115,13 +116,23 @@ struct TimetableWeekView: View {
.lineLimit(1) .lineLimit(1)
Text("\(Calendar.current.component(.day, from: day))") Text("\(Calendar.current.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .regular)) .font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) .foregroundStyle(isToday ? todayNumberColor : palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
.background(Circle().fill(isToday ? palette.accent : Color.clear)) .background(Circle().fill(isToday ? todayCircleColor : Color.clear))
} }
} }
// An opaque accent circle flattens to the same shade as its number on
// tinted / clear home screens; keep the circle translucent there.
private var todayCircleColor: Color {
renderingMode == .fullColor ? palette.accent : Color.white.opacity(0.3)
}
private var todayNumberColor: Color {
renderingMode == .fullColor ? palette.onAccent : palette.textPrimary
}
private func placeholder(_ message: String) -> some View { private func placeholder(_ message: String) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Marianum Stundenplan") Text("Marianum Stundenplan")
@@ -201,12 +201,15 @@ private struct WidgetSurface<Content: View>: View {
let entry: TimetableEntry let entry: TimetableEntry
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.widgetRenderingMode) private var renderingMode
var body: some View { var body: some View {
let palette = WidgetPalette.resolve( // Tinted / clear home screens map every colour to its luminance, so
themeMode: entry.themeMode, // the light palette's near-black text would vanish — use the dark
colorScheme: colorScheme // palette's light text there regardless of the theme setting.
) let palette = renderingMode == .fullColor
? WidgetPalette.resolve(themeMode: entry.themeMode, colorScheme: colorScheme)
: .dark
return AnyView( return AnyView(
background(content: content(), palette: palette) background(content: content(), palette: palette)
.environment(\.widgetPalette, palette) .environment(\.widgetPalette, palette)
+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,
};
}
+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;
}
+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;
} }
+18 -8
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';
@@ -63,15 +65,23 @@ AppException _dynamiteToAppException(DynamiteApiException error) {
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) {
return ServerException( case 401:
statusCode: status, return AuthException.unauthorized(technicalDetails: detail);
userMessage: case 403:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', return AuthException.forbidden(technicalDetails: detail);
technicalDetails: detail, case 404:
); return NotFoundException(technicalDetails: detail);
case 429:
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
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',
);
} }
+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));
} }
@@ -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'),
// Deliberately NOT the shared Authorization value: that one prefers headers: {
// the app password, but an app password cannot mint another one — ...NextcloudOcs.headers(),
// this endpoint requires the real password. // Deliberately NOT the shared Authorization value: that one prefers
headers: NextcloudOcs.headers( // the app password, but an app password cannot mint another one —
authorization: SessionManager() // this endpoint requires the real password.
'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;
@@ -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?) ?? '',
@@ -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,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,
), );
// A page holds up to 200 messages and lands while the user is scrolling; final uri = NextcloudOcs.uri(
// decoding it on the UI isolate stalls the fling. 'apps/spreed/api/v1/chat/$chatToken',
parseOffIsolate: true, 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;
// decoding it on the UI isolate stalls the fling.
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));
@@ -70,15 +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 getTextDummy(String text) => static GetChatResponseObject getTextDummy(String text) =>
GetChatResponseObject( GetChatResponseObject(
@@ -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',
);
}
} }
@@ -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,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);
} }
@@ -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);
} }
@@ -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);
} }
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
@@ -16,6 +17,13 @@ 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) =>
+1 -2
View File
@@ -23,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`
+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);
@@ -81,8 +81,8 @@ class MarianumConnectTokenStorage {
} }
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.
@@ -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,
);
} }
@@ -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,
},
};
}
@@ -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.
@@ -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) =>
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?, userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -24,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,
};
@@ -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) {
@@ -0,0 +1,9 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
class GetParentLetter extends ParentLetterQuery {
GetParentLetter({super.dio});
Future<ParentLetterDetail> run(String letterId) =>
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
}
@@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'parent_letter_query.dart';
class GetParentLetterAttachment extends ParentLetterQuery {
GetParentLetterAttachment({super.dio});
Future<Uint8List> run({
required String letterId,
required String attachmentId,
}) => getBytes(
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
);
}
@@ -0,0 +1,16 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `GET parent-letters`: the guardian's inbox across all children, newest
/// first. [before] continues after the given letter (keyset paging).
class GetParentLetters extends ParentLetterQuery {
static const int pageSize = 50;
GetParentLetters({super.dio});
Future<ParentLetterListResponse> run({String? before}) => getObject(
'parent-letters',
ParentLetterListResponse.fromJson,
queryParameters: {'limit': pageSize, 'before': ?before},
);
}
@@ -0,0 +1,11 @@
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
/// for this guardian. Idempotent.
class MarkParentLetterRead extends ParentLetterQuery {
MarkParentLetterRead({super.dio});
Future<void> run(String letterId) => guard(() async {
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
});
}
@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum ParentLetterError {
guardianRequired,
letterNotFound,
childNotFound,
attachmentNotFound,
invalidAnswers,
signatureRequired,
responseFinal,
deadlinePassed,
threadDisabled,
invalidRequest,
}
/// A parent-letter call the server rejected with a domain reason.
class ParentLetterException extends AppException {
final ParentLetterError error;
const ParentLetterException(
this.error, {
required super.userMessage,
super.technicalDetails,
}) : super(allowRetry: false);
/// Only 4xx answers with a parent-letter reason become a
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
/// generic MarianumConnect mapping.
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500 || status == 401) {
return mapMarianumConnectError(e);
}
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
if (error == null) return mapMarianumConnectError(e);
return ParentLetterException(
error,
userMessage: messageFor(error),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(ParentLetterError error) => switch (error) {
ParentLetterError.guardianRequired =>
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
ParentLetterError.letterNotFound =>
'Dieser Elternbrief ist nicht mehr verfügbar.',
ParentLetterError.childNotFound =>
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
ParentLetterError.attachmentNotFound =>
'Der Anhang ist nicht mehr verfügbar.',
ParentLetterError.invalidAnswers =>
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
ParentLetterError.signatureRequired =>
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
ParentLetterError.responseFinal =>
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
'geändert werden.',
ParentLetterError.deadlinePassed =>
'Die Frist für diese Rückmeldung ist abgelaufen.',
ParentLetterError.threadDisabled =>
'Auf diesen Elternbrief kann nicht geantwortet werden.',
ParentLetterError.invalidRequest =>
'Die Anfrage konnte nicht verarbeitet werden.',
};
static ParentLetterError? _errorFor(String? code, int status) =>
switch (code) {
'guardian_required' => ParentLetterError.guardianRequired,
'letter_not_found' => ParentLetterError.letterNotFound,
'child_not_found' => ParentLetterError.childNotFound,
'attachment_not_found' => ParentLetterError.attachmentNotFound,
'invalid_answers' => ParentLetterError.invalidAnswers,
'signature_required' => ParentLetterError.signatureRequired,
'response_final' => ParentLetterError.responseFinal,
'deadline_passed' => ParentLetterError.deadlinePassed,
'thread_disabled' => ParentLetterError.threadDisabled,
'invalid_request' => ParentLetterError.invalidRequest,
_ => switch (status) {
404 => ParentLetterError.letterNotFound,
409 => ParentLetterError.responseFinal,
410 => ParentLetterError.deadlinePassed,
_ => null,
},
};
}
@@ -0,0 +1,226 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'parent_letter_models.freezed.dart';
part 'parent_letter_models.g.dart';
enum ParentLetterStatus { info, open, done, expired }
enum ParentLetterFieldType {
@JsonValue('single_choice')
singleChoice,
unknown,
}
/// Sender, thread author or responding guardian. [self] marks the signed-in
/// guardian.
@freezed
abstract class ParentLetterPerson with _$ParentLetterPerson {
const factory ParentLetterPerson({
@Default('') String displayName,
@Default(false) bool self,
}) = _ParentLetterPerson;
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
_$ParentLetterPersonFromJson(json);
}
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
/// child ids from `me/capabilities`.
@freezed
abstract class ParentLetterSummary with _$ParentLetterSummary {
const factory ParentLetterSummary({
required String id,
@Default('') String subject,
@Default('') String preview,
@Default(ParentLetterPerson()) ParentLetterPerson sender,
required DateTime sentAt,
DateTime? editedAt,
@Default(true) bool read,
@Default([]) List<String> childIds,
@Default(0) int attachmentCount,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
DateTime? deadline,
}) = _ParentLetterSummary;
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
_$ParentLetterSummaryFromJson(json);
}
@freezed
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
const factory ParentLetterListResponse({
@Default([]) List<ParentLetterSummary> items,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLetterListResponse;
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterListResponseFromJson(json);
}
@freezed
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
const factory ParentLetterAttachment({
required String id,
@Default('') String fileName,
@Default('') String mimeType,
@Default(0) int size,
}) = _ParentLetterAttachment;
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAttachmentFromJson(json);
}
@freezed
abstract class ParentLetterOption with _$ParentLetterOption {
const factory ParentLetterOption({
required String id,
@Default('') String label,
}) = _ParentLetterOption;
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
_$ParentLetterOptionFromJson(json);
}
@freezed
abstract class ParentLetterField with _$ParentLetterField {
const factory ParentLetterField({
required String id,
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
@Default(ParentLetterFieldType.unknown)
ParentLetterFieldType type,
@Default('') String label,
@JsonKey(name: 'required') @Default(false) bool isRequired,
@Default([]) List<ParentLetterOption> options,
}) = _ParentLetterField;
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
_$ParentLetterFieldFromJson(json);
}
/// What the sender asks for. No fields and no signature means a plain
/// acknowledgement.
@freezed
abstract class ParentLetterRequest with _$ParentLetterRequest {
const factory ParentLetterRequest({
@Default([]) List<ParentLetterField> fields,
@Default(false) bool signatureRequired,
DateTime? deadline,
}) = _ParentLetterRequest;
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
_$ParentLetterRequestFromJson(json);
}
@freezed
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
const factory ParentLetterAnswer({
required String fieldId,
@Default([]) List<String> optionIds,
String? text,
}) = _ParentLetterAnswer;
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAnswerFromJson(json);
}
@freezed
abstract class ParentLetterResponse with _$ParentLetterResponse {
const factory ParentLetterResponse({
DateTime? respondedAt,
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
@Default([]) List<ParentLetterAnswer> answers,
@Default(false) bool signed,
}) = _ParentLetterResponse;
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterResponseFromJson(json);
}
/// Per-child state of a letter. [editable] is the server's verdict on whether
/// this guardian may (re)submit right now.
@freezed
abstract class ParentLetterChildState with _$ParentLetterChildState {
const factory ParentLetterChildState({
required String childId,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
@Default(false) bool editable,
ParentLetterResponse? response,
}) = _ParentLetterChildState;
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterChildStateFromJson(json);
}
@freezed
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
const factory ParentLetterThreadMessage({
required String id,
@Default(ParentLetterPerson()) ParentLetterPerson author,
@Default('') String body,
required DateTime sentAt,
}) = _ParentLetterThreadMessage;
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadMessageFromJson(json);
}
@freezed
abstract class ParentLetterThread with _$ParentLetterThread {
const factory ParentLetterThread({
@Default(false) bool enabled,
@Default([]) List<ParentLetterThreadMessage> messages,
}) = _ParentLetterThread;
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadFromJson(json);
}
/// The detail-only part of `GET parent-letters/{id}`.
@freezed
abstract class ParentLetterContent with _$ParentLetterContent {
const factory ParentLetterContent({
@Default('') String body,
@Default([]) List<ParentLetterAttachment> attachments,
ParentLetterRequest? request,
@Default([]) List<ParentLetterChildState> children,
@Default(ParentLetterThread()) ParentLetterThread thread,
}) = _ParentLetterContent;
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
_$ParentLetterContentFromJson(json);
}
/// A full letter. The server sends the inbox fields and the detail fields in
/// one flat object; they are parsed into [summary] and [content] so the inbox
/// entry can be replaced from a detail answer without copying fields.
@immutable
class ParentLetterDetail {
final ParentLetterSummary summary;
final ParentLetterContent content;
const ParentLetterDetail({required this.summary, required this.content});
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
ParentLetterDetail(
summary: ParentLetterSummary.fromJson(json),
content: ParentLetterContent.fromJson(json),
);
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
@override
bool operator ==(Object other) =>
other is ParentLetterDetail &&
other.summary == summary &&
other.content == content;
@override
int get hashCode => Object.hash(summary, content);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,331 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
_ParentLetterPerson(
displayName: json['displayName'] as String? ?? '',
self: json['self'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'self': instance.self,
};
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
_ParentLetterSummary(
id: json['id'] as String,
subject: json['subject'] as String? ?? '',
preview: json['preview'] as String? ?? '',
sender: json['sender'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
sentAt: DateTime.parse(json['sentAt'] as String),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
read: json['read'] as bool? ?? true,
childIds:
(json['childIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterSummaryToJson(
_ParentLetterSummary instance,
) => <String, dynamic>{
'id': instance.id,
'subject': instance.subject,
'preview': instance.preview,
'sender': instance.sender,
'sentAt': instance.sentAt.toIso8601String(),
'editedAt': instance.editedAt?.toIso8601String(),
'read': instance.read,
'childIds': instance.childIds,
'attachmentCount': instance.attachmentCount,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'deadline': instance.deadline?.toIso8601String(),
};
const _$ParentLetterStatusEnumMap = {
ParentLetterStatus.info: 'info',
ParentLetterStatus.open: 'open',
ParentLetterStatus.done: 'done',
ParentLetterStatus.expired: 'expired',
};
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterListResponse(
items:
(json['items'] as List<dynamic>?)
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterListResponseToJson(
_ParentLetterListResponse instance,
) => <String, dynamic>{
'items': instance.items,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
Map<String, dynamic> json,
) => _ParentLetterAttachment(
id: json['id'] as String,
fileName: json['fileName'] as String? ?? '',
mimeType: json['mimeType'] as String? ?? '',
size: (json['size'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterAttachmentToJson(
_ParentLetterAttachment instance,
) => <String, dynamic>{
'id': instance.id,
'fileName': instance.fileName,
'mimeType': instance.mimeType,
'size': instance.size,
};
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
_ParentLetterOption(
id: json['id'] as String,
label: json['label'] as String? ?? '',
);
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
<String, dynamic>{'id': instance.id, 'label': instance.label};
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
_ParentLetterField(
id: json['id'] as String,
type:
$enumDecodeNullable(
_$ParentLetterFieldTypeEnumMap,
json['type'],
unknownValue: ParentLetterFieldType.unknown,
) ??
ParentLetterFieldType.unknown,
label: json['label'] as String? ?? '',
isRequired: json['required'] as bool? ?? false,
options:
(json['options'] as List<dynamic>?)
?.map(
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
<String, dynamic>{
'id': instance.id,
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
'label': instance.label,
'required': instance.isRequired,
'options': instance.options,
};
const _$ParentLetterFieldTypeEnumMap = {
ParentLetterFieldType.singleChoice: 'single_choice',
ParentLetterFieldType.unknown: 'unknown',
};
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
_ParentLetterRequest(
fields:
(json['fields'] as List<dynamic>?)
?.map(
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
signatureRequired: json['signatureRequired'] as bool? ?? false,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterRequestToJson(
_ParentLetterRequest instance,
) => <String, dynamic>{
'fields': instance.fields,
'signatureRequired': instance.signatureRequired,
'deadline': instance.deadline?.toIso8601String(),
};
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
_ParentLetterAnswer(
fieldId: json['fieldId'] as String,
optionIds:
(json['optionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
text: json['text'] as String?,
);
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
<String, dynamic>{
'fieldId': instance.fieldId,
'optionIds': instance.optionIds,
'text': instance.text,
};
_ParentLetterResponse _$ParentLetterResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterResponse(
respondedAt: json['respondedAt'] == null
? null
: DateTime.parse(json['respondedAt'] as String),
respondedBy: json['respondedBy'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(
json['respondedBy'] as Map<String, dynamic>,
),
answers:
(json['answers'] as List<dynamic>?)
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
signed: json['signed'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterResponseToJson(
_ParentLetterResponse instance,
) => <String, dynamic>{
'respondedAt': instance.respondedAt?.toIso8601String(),
'respondedBy': instance.respondedBy,
'answers': instance.answers,
'signed': instance.signed,
};
_ParentLetterChildState _$ParentLetterChildStateFromJson(
Map<String, dynamic> json,
) => _ParentLetterChildState(
childId: json['childId'] as String,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
editable: json['editable'] as bool? ?? false,
response: json['response'] == null
? null
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterChildStateToJson(
_ParentLetterChildState instance,
) => <String, dynamic>{
'childId': instance.childId,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'editable': instance.editable,
'response': instance.response,
};
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
Map<String, dynamic> json,
) => _ParentLetterThreadMessage(
id: json['id'] as String,
author: json['author'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
body: json['body'] as String? ?? '',
sentAt: DateTime.parse(json['sentAt'] as String),
);
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
_ParentLetterThreadMessage instance,
) => <String, dynamic>{
'id': instance.id,
'author': instance.author,
'body': instance.body,
'sentAt': instance.sentAt.toIso8601String(),
};
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
_ParentLetterThread(
enabled: json['enabled'] as bool? ?? false,
messages:
(json['messages'] as List<dynamic>?)
?.map(
(e) => ParentLetterThreadMessage.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'messages': instance.messages,
};
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
_ParentLetterContent(
body: json['body'] as String? ?? '',
attachments:
(json['attachments'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
request: json['request'] == null
? null
: ParentLetterRequest.fromJson(
json['request'] as Map<String, dynamic>,
),
children:
(json['children'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
thread: json['thread'] == null
? const ParentLetterThread()
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterContentToJson(
_ParentLetterContent instance,
) => <String, dynamic>{
'body': instance.body,
'attachments': instance.attachments,
'request': instance.request,
'children': instance.children,
'thread': instance.thread,
};
@@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart';
import 'parent_letter_exception.dart';
/// Base for the parent-letter calls: domain rejections surface as
/// [ParentLetterException].
abstract class ParentLetterQuery extends MarianumConnectQuery {
ParentLetterQuery({super.dio});
String letterPath(String letterId, [String suffix = '']) =>
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
@override
AppException mapError(DioException error) =>
ParentLetterException.fromDio(error);
}
@@ -0,0 +1,20 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
/// idempotent on the server.
class PostParentLetterThreadMessage extends ParentLetterQuery {
PostParentLetterThreadMessage({super.dio});
Future<ParentLetterThreadMessage> run({
required String letterId,
required String body,
required String clientMessageId,
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint(letterPath(letterId, '/thread')),
data: {'body': body, 'clientMessageId': clientMessageId},
);
return ParentLetterThreadMessage.fromJson(response.data!);
});
}
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'dart:typed_data';
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
/// replaces the response for one child. Answers with the updated letter.
class SubmitParentLetterResponse extends ParentLetterQuery {
SubmitParentLetterResponse({super.dio});
Future<ParentLetterDetail> run({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => guard(() async {
final response = await dio.put<Map<String, dynamic>>(
endpoint(
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
),
data: {
'answers': [
for (final answer in answers)
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
],
'signaturePng': signaturePng == null
? null
: base64Encode(signaturePng),
},
);
return ParentLetterDetail.fromJson(response.data!);
});
}
@@ -1,16 +1,18 @@
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via /// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud /// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
/// device-identifier signature, stores the routing metadata and starts /// verifies the device-identifier signature and forwards Nextcloud pushes to
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204. /// this device's FCM token; `direct` registrations (accounts without
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
/// Responds 204.
class PushDeviceRegister extends MarianumConnectQuery { class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio}); PushDeviceRegister({super.dio});
Future<void> run({ Future<void> run({
required String deviceIdentifier, required String deviceIdentifier,
required String deviceIdentifierSignature, String? deviceIdentifierSignature,
required String userPublicKey, String? userPublicKey,
required String pushToken, required String pushToken,
required String platform, required String platform,
required String registrationType, required String registrationType,
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
endpoint('me/push-device'), endpoint('me/push-device'),
data: { data: {
'deviceIdentifier': deviceIdentifier, 'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature, 'deviceIdentifierSignature': ?deviceIdentifierSignature,
'userPublicKey': userPublicKey, 'userPublicKey': ?userPublicKey,
'pushToken': pushToken, 'pushToken': pushToken,
'platform': platform, 'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison // 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')). // value from it (general = sha512(token), talk = sha512(token+'#talk')).
// 'direct' — no Nextcloud subscription behind it.
'registrationType': registrationType, 'registrationType': registrationType,
'appVersion': ?appVersion, 'appVersion': ?appVersion,
}, },
@@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import 'report_client_error.dart'; import 'report_client_error.dart';
/// Fire-and-forget bridge from the app's global error handlers to the /// Fire-and-forget bridge from the app's global error handlers to the
@@ -61,7 +61,7 @@ class ClientErrorReporter {
message: message, message: message,
stacktrace: stacktrace, stacktrace: stacktrace,
context: context, context: context,
platform: clientPlatform(), platform: _platform(),
appVersion: _appVersion, appVersion: _appVersion,
); );
} catch (e) { } catch (e) {
@@ -73,4 +73,10 @@ class ClientErrorReporter {
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#'); final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
return '$errorType|$normalized|${context ?? ''}'; return '$errorType|$normalized|${context ?? ''}';
} }
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
} }
@@ -5,7 +5,6 @@ import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth). /// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
@@ -28,7 +27,7 @@ class SubmitFeedback extends MarianumConnectQuery {
'message': message, 'message': message,
'screenshot': ?screenshotBase64, 'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null, 'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': clientPlatform(), 'platform': _platform(),
'appVersion': package.version, 'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber), 'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(), 'deviceModel': await _deviceModel(),
@@ -36,6 +35,12 @@ class SubmitFeedback extends MarianumConnectQuery {
); );
}); });
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
static Future<String?> _deviceModel() async { static Future<String?> _deviceModel() async {
try { try {
final info = DeviceInfoPlugin(); final info = DeviceInfoPlugin();
@@ -26,7 +26,8 @@ class CustomEventsMigration {
const CustomEventsMigration._(); const CustomEventsMigration._();
static Future<void> runOnce() async { static Future<void> runOnce() async {
// Only password accounts can derive the legacy identity. // Guardians never had MHSL events; only password accounts can derive the
// legacy identity.
final session = SessionManager().current; final session = SessionManager().current;
if (session is! CredentialSession || session.isDemo) return; if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return; if (await _isDone()) return;
@@ -0,0 +1,18 @@
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
/// Fetches the weekly timetable of a guardian's child from
/// `timetable/child/{childId}`. Same response shape as `timetable/me`.
class TimetableGetChildWeek extends MarianumConnectQuery {
TimetableGetChildWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required String childId,
required DateTime from,
required DateTime until,
}) => getObject(
'timetable/child/${Uri.encodeComponent(childId)}',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
+66 -51
View File
@@ -19,6 +19,7 @@ import 'push/push_tap_router.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/session_manager.dart'; import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/infrastructure/loadable_state/loadable_state.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
@@ -26,13 +27,16 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'state/app/modules/timetable/bloc/timetable_state.dart'; import 'state/app/modules/timetable/bloc/timetable_state.dart';
import 'state/app/modules/timetable/policy/timetable_policy.dart';
import 'storage/settings.dart' as model; import 'storage/settings.dart' as model;
import 'utils/debouncer.dart'; import 'utils/debouncer.dart';
import 'utils/haptics.dart'; import 'utils/haptics.dart';
import 'view/pages/overhang.dart'; import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_navigation.dart'; import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart'; import 'widget_data/widget_publisher.dart';
import 'widget_data/widget_sync.dart';
class App extends StatefulWidget { class App extends StatefulWidget {
const App({super.key}); const App({super.key});
@@ -42,7 +46,6 @@ class App extends StatefulWidget {
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub; StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub; StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub; StreamSubscription<String>? _fcmTokenRefreshSub;
@@ -80,25 +83,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
/// Shares one debounce key, so a later emit supersedes a pending publish.
void _scheduleWidgetPublish(TimetableState data, Duration delay) {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
final epoch = SessionManager().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
delay,
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
void _reportTelemetry() { void _reportTelemetry() {
if (!mounted) return; if (!mounted) return;
TelemetryHeartbeat.report( TelemetryHeartbeat.report(
@@ -144,18 +128,32 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final pending = await WidgetNavigation.consumePendingTimetableTap(); final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return; if (!pending || !mounted) return;
// `withNavBar: false` routes sit on the root navigator above the // `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. // bottom-nav; pop them so jumpToTab is actually visible. Stop at
_popToShell(); // popups so open dialogs/sheets stay alive.
AppRoutes.goToTab(context, Modules.timetable);
}
/// Pops full-page routes back to the tab shell, stopping at popups so open
/// dialogs/sheets stay alive.
void _popToShell() {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.goToTab(context, Modules.timetable);
}
/// Mirrors the primary plan into the home-screen widget without waiting
/// for the periodic background refresh.
void _publishWidget(TimetableBloc bloc) {
final data = bloc.state.data;
if (!mounted || data is! TimetableState) return;
if (WidgetSync.encodeSubject(bloc.subject) == null) return;
unawaited(
WidgetPublisher.publishFromBlocState(
data,
subject: bloc.subject,
settings: context.read<SettingsCubit>().val(),
showClassInsteadOfTeacher: TimetablePolicy.resolve(
subject: bloc.subject,
capabilities: context.read<CapabilitiesCubit>().state,
).showClassInsteadOfTeacher,
),
);
} }
void _handlePendingShare() { void _handlePendingShare() {
@@ -164,7 +162,22 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (share == null || ShareIntentListener.instance.isShown(share)) return; if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
_popToShell(); // Sharing targets Talk chats and Files folders only.
final session = SessionManager().current;
if (!AppModule.isAvailableFor(Modules.talk, session) &&
!AppModule.isAvailableFor(Modules.files, session)) {
ShareIntentListener.instance.clear();
InfoDialog.show(
context,
'Mit diesem Konto können keine Inhalte in die App geteilt werden.',
title: 'Teilen nicht möglich',
);
return;
}
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
ShareIntentListener.instance.markShown(share); ShareIntentListener.instance.markShown(share);
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -182,27 +195,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset. // Initial publish in case hydrated storage already has data. No refresh
final timetable = context.read<TimetableBloc>(); // needed: PrimaryTimetableScope hands out a freshly loading bloc. The
timetable.refresh(); // widget still shows its last snapshot, so this waits until the
// Mirror BLoC updates into the home-screen widget without waiting // cold-start frames are done (a bloc emit in the meantime supersedes it).
// for the periodic background refresh. Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
_timetableWidgetSync?.cancel(); if (mounted) _publishWidget(context.read<TimetableBloc>());
// Debounced: a refresh or week swipe emits several times in a row, and
// only the settled state is worth mirroring.
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
_scheduleWidgetPublish(data, const Duration(seconds: 1));
}
}); });
// Initial publish in case hydrated storage already has data. The widget
// still shows its last snapshot, so this waits until the cold-start
// frames are done (a fresh bloc emit in the meantime supersedes it).
final initialData = timetable.state.data;
if (initialData is TimetableState) {
_scheduleWidgetPublish(initialData, const Duration(seconds: 3));
}
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare(); _handlePendingShare();
@@ -260,7 +259,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
@@ -272,7 +270,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
@override @override
Widget build( Widget build(BuildContext context) =>
BlocListener<TimetableBloc, LoadableState<TimetableState>>(
// Also follows a swapped instance (child switch), unlike a manual
// stream subscription.
listenWhen: (_, state) => !state.isLoading,
// A week change emits several times (week, prefetched neighbours);
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 1),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
),
child: _buildShell(context),
);
Widget _buildShell(
BuildContext context, BuildContext context,
) => BlocSelector<SettingsCubit, model.Settings, Object>( ) => BlocSelector<SettingsCubit, model.Settings, Object>(
// Only the module layout shapes the shell; copied values because the // Only the module layout shapes the shell; copied values because the
+22
View File
@@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// Binds a guardian login request to the device that started it (PKCE-style):
/// the request carries only [challengeFor] of a secret that never leaves the
/// device, verification sends the secret itself. A mail link opened on another
/// device therefore cannot complete the login.
abstract final class DeviceBinding {
static String generateSecret([Random? random]) {
final rng = random ?? Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return _base64UrlNoPad(bytes);
}
static String challengeFor(String secret) =>
_base64UrlNoPad(sha256.convert(utf8.encode(secret)).bytes);
static String _base64UrlNoPad(List<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'guardian_login_link.dart';
/// Bridges incoming App Links / Universal Links into [pending]; the login
/// screen consumes it. Mirrors ShareIntentListener: [initialize] reads the
/// cold-start link before `runApp` and then follows links while running.
///
/// Links are kept raw and parsed on consumption: the endpoint setting the
/// link is checked against is only applied once the app is built.
class GuardianLinkListener {
GuardianLinkListener._();
static final GuardianLinkListener instance = GuardianLinkListener._();
static final ValueNotifier<Uri?> pending = ValueNotifier(null);
final AppLinks _appLinks = AppLinks();
bool _listening = false;
Future<void> initialize() async {
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) _publish(initial);
} catch (e) {
debugPrint('GuardianLinkListener.initialize failed: $e');
}
if (_listening) return;
_listening = true;
// Kept for the whole process lifetime; links can arrive at any time.
_appLinks.uriLinkStream.listen(
_publish,
onError: (Object e) => debugPrint('GuardianLinkListener error: $e'),
);
}
// Cheap pre-filter; the host check against the active endpoint happens in
// GuardianLoginLink.parse.
void _publish(Uri uri) {
if (!uri.path.endsWith(GuardianLoginLink.path)) return;
pending.value = uri;
}
static void clear() => pending.value = null;
}
+26
View File
@@ -0,0 +1,26 @@
/// A sign-in link from the guardian login mail
/// (`https://<connect-host>/app/guardian-login?rid=…&lt=…`).
class GuardianLoginLink {
static const path = '/app/guardian-login';
final String requestId;
final String linkToken;
const GuardianLoginLink({required this.requestId, required this.linkToken});
/// Parses [uri] if it is a guardian login link for the server at [apiBase].
/// Links of another server (e.g. live link while the app points at beta)
/// are rejected: the request only exists on the server that sent the mail.
static GuardianLoginLink? parse(Uri uri, {required Uri apiBase}) {
if (uri.scheme != 'https' || uri.host != apiBase.host) return null;
final basePath = apiBase.path.endsWith('/')
? apiBase.path.substring(0, apiBase.path.length - 1)
: apiBase.path;
if (uri.path != '$basePath$path') return null;
final requestId = uri.queryParameters['rid'];
final linkToken = uri.queryParameters['lt'];
if (requestId == null || requestId.isEmpty) return null;
if (linkToken == null || linkToken.isEmpty) return null;
return GuardianLoginLink(requestId: requestId, linkToken: linkToken);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A guardian login request waiting for its code or link. Persisted because
/// Android often kills the app while the user reads the mail.
class PendingGuardianRequest {
static const int defaultCodeLength = 6;
final String requestId;
final String email;
final String deviceSecret;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const PendingGuardianRequest({
required this.requestId,
required this.email,
required this.deviceSecret,
required this.expiresAt,
required this.resendAvailableAt,
this.codeLength = defaultCodeLength,
});
bool isExpired(DateTime now) => !now.isBefore(expiresAt);
Map<String, Object> toJson() => {
'requestId': requestId,
'email': email,
'deviceSecret': deviceSecret,
'expiresAt': expiresAt.toIso8601String(),
'resendAvailableAt': resendAvailableAt.toIso8601String(),
'codeLength': codeLength,
};
static PendingGuardianRequest? fromJson(Map<String, dynamic> json) {
try {
return PendingGuardianRequest(
requestId: json['requestId'] as String,
email: json['email'] as String,
deviceSecret: json['deviceSecret'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength: json['codeLength'] as int? ?? defaultCodeLength,
);
} on Object {
return null;
}
}
}
class PendingGuardianRequestStore {
static const _key = 'guardian_login_pending_request';
static const FlutterSecureStorage _storage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
const PendingGuardianRequestStore();
Future<PendingGuardianRequest?> read() async {
try {
final raw = await _storage.read(key: _key);
if (raw == null) return null;
return PendingGuardianRequest.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on Object {
return null;
}
}
Future<void> write(PendingGuardianRequest request) =>
_storage.write(key: _key, value: jsonEncode(request.toJson()));
Future<void> clear() async {
try {
await _storage.delete(key: _key);
} on Object {
// Nothing stored or keystore unavailable.
}
}
}
+86 -23
View File
@@ -9,14 +9,17 @@ import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart'; import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart';
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../extensions/date_time.dart'; import '../session/session.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
import '../state/app/modules/timetable/data_provider/timetable_data_provider.dart';
import '../state/app/modules/timetable/subject/timetable_subject.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
@@ -79,12 +82,16 @@ class WidgetBackgroundTask {
/// Throws on fetch failure so the worker path can signal a retry. /// Throws on fetch failure so the worker path can signal a retry.
static Future<void> runRefreshNow({bool force = false}) async { static Future<void> runRefreshNow({bool force = false}) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
// Bounded: a hanging keystore read must not stall the caller's budget Session? session;
// (FCM handler ~25s on iOS) forever. try {
final session = await SessionManager().waitForLoad().timeout( // Bounded: a hanging keystore read must not stall the caller's budget
const Duration(seconds: 10), // (FCM handler ~25s on iOS) forever.
onTimeout: () => null, session = await SessionManager().waitForLoad().timeout(
); const Duration(seconds: 10),
);
} on TimeoutException {
session = null;
}
if (session == null) { if (session == null) {
// Deliberately does NOT flip the widget to logged-out: a failed or slow // Deliberately does NOT flip the widget to logged-out: a failed or slow
// keychain read (locked iOS device during the 06:00 silent push) is // keychain read (locked iOS device during the 06:00 silent push) is
@@ -95,11 +102,23 @@ class WidgetBackgroundTask {
return; return;
} }
final fetchedAt = await WidgetSync.getFetchedAt(); final fetchedAt = await WidgetSync.getFetchedAt();
if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { if (shouldSkipRefresh(
fetchedAt: fetchedAt,
now: DateTime.now(),
force: force,
)) {
log('[widget-refresh] snapshot is fresh, skipping refresh'); log('[widget-refresh] snapshot is fresh, skipping refresh');
return; return;
} }
await _refresh(); final subject = widgetRefreshSubject(
session: session,
stored: await WidgetSync.getSubject(),
);
if (subject == null) {
log('[widget-refresh] no plan selected yet, skipping refresh');
return;
}
await _refresh(subject);
} }
static Future<void> cancelAll() async { static Future<void> cancelAll() async {
@@ -120,6 +139,16 @@ bool shouldSkipRefresh({
return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
} }
/// The plan the background refresh loads. Guardians only get one once the app
/// has published a child; before that there is nothing sensible to fetch.
TimetableSubject? widgetRefreshSubject({
required Session session,
required TimetableSubject? stored,
}) => switch (session) {
CredentialSession() => const OwnTimetable(),
GuardianSession() => stored is ChildTimetable ? stored : null,
};
@pragma('vm:entry-point') @pragma('vm:entry-point')
void _callbackDispatcher() { void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
@@ -136,7 +165,8 @@ void _callbackDispatcher() {
}); });
} }
Future<void> _refresh() async { Future<void> _refresh(TimetableSubject subject) async {
await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we // The background isolate doesn't go through main.dart's BlocBuilder, so we
// re-apply the endpoint the foreground last persisted. Without this the // re-apply the endpoint the foreground last persisted. Without this the
// dio singleton would fall back to its hardcoded live default even when // dio singleton would fall back to its hardcoded live default even when
@@ -150,50 +180,83 @@ Future<void> _refresh() async {
// Fetch window matches the week payload's window so the widget can roll // Fetch window matches the week payload's window so the widget can roll
// forward into next week's lessons without fresh data. // forward into next week's lessons without fresh data.
final weekStart = WidgetDataMapper.startOfCalendarWeek(now); final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
final weekEndExclusive = weekStart.addDays(WidgetDataMapper.weekWindowDays); final weekEndExclusive = weekStart.add(
const Duration(days: WidgetDataMapper.weekWindowDays),
);
// All six requests are independent — run them concurrently so the total // All six requests are independent — run them concurrently so the total
// latency is the slowest request, not the sum (matters for the push path's // latency is the slowest request, not the sum (matters for the push path's
// hard time budget). Reference-data failures fall through to null in the // hard time budget). Reference-data failures fall through to null in the
// mapper rather than aborting the whole refresh. // mapper rather than aborting the whole refresh.
final timetableFuture = TimetableGetWeek().run( final until = weekEndExclusive.subtract(const Duration(days: 1));
final timetableFuture = TimetableDataProvider.fetchWeek(
subject,
from: weekStart, from: weekStart,
until: weekEndExclusive.subtractDays(1), until: until,
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>( final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
); );
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
() => TimetableGetRooms().run(),
);
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>( final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
() => TimetableGetHolidays().run(), () => TimetableGetHolidays().run(),
); );
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>( final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(), () => TimetableGetTimegrid().run(),
); );
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>( final customEventsFuture = subject.supportsCustomEvents
() => TimetableCustomEventsGet().run(), ? _runOrNull<GetCustomTimetableEventResponse>(
); () => TimetableCustomEventsGet().run(),
)
: Future<GetCustomTimetableEventResponse?>.value();
final timetable = await timetableFuture; final timetable = await timetableFuture;
final subjects = await subjectsFuture; final subjects = await subjectsFuture;
final rooms = await roomsFuture;
final holidays = await holidaysFuture; final holidays = await holidaysFuture;
final timegrid = await timegridFuture; final timegrid = await timegridFuture;
final customEvents = await customEventsFuture; final customEvents = await customEventsFuture;
final [connectDouble, isTeacher] = await Future.wait([ final lessons = timetable.entries;
final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(), WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(), WidgetSync.getShowClassInsteadOfTeacher(),
]); ]);
final (day: dayData, week: weekData) = WidgetDataMapper.buildSnapshot( final dayData = WidgetDataMapper.buildDayData(
now: now, now: now,
lessons: timetable.entries, lessons: lessons,
subjects: subjects, subjects: subjects,
rooms: rooms,
holidays: holidays, holidays: holidays,
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
lessons: lessons,
subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
await WidgetSync.publishSnapshot(day: dayData, week: weekData); // The user may have switched the child while the requests ran; writing now
// would show the previous child's plan.
if (await WidgetSync.getSubject() != subject) {
log('[widget-bg] subject changed during refresh, discarding');
return;
}
await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true);
await WidgetSync.triggerUpdate();
log( log(
'[widget-bg] refreshed: day=${dayData.lessons.length} ' '[widget-bg] refreshed: day=${dayData.lessons.length} '
'week=${weekData.lessons.length}', 'week=${weekData.lessons.length}',
+3 -1
View File
@@ -72,7 +72,9 @@ extension DateTimeFormatting on DateTime {
String formatDateRelativeShort({DateTime? now}) { String formatDateRelativeShort({DateTime? now}) {
final reference = now ?? DateTime.now(); final reference = now ?? DateTime.now();
final diff = reference.dateOnly.difference(dateOnly).inDays; final today = DateTime(reference.year, reference.month, reference.day);
final self = DateTime(year, month, day);
final diff = today.difference(self).inDays;
if (diff == 0) return 'Heute'; if (diff == 0) return 'Heute';
if (diff == 1) return 'Gestern'; if (diff == 1) return 'Gestern';
+58 -15
View File
@@ -11,13 +11,12 @@ import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'api/demo/demo_http_client.dart'; import 'access/user_role.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -25,10 +24,12 @@ import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart'; import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/session_wipe.dart'; import 'model/session_wipe.dart';
import 'notification/notification_service.dart'; import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart'; import 'push/push_message_handler.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_registration_store.dart'; import 'push/push_registration_store.dart';
@@ -45,13 +46,14 @@ import 'state/app/modules/app_modules.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'storage/account_storage.dart'; import 'storage/account_storage.dart';
import 'storage/settings.dart'; import 'storage/settings.dart';
import 'theming/dark_app_theme.dart'; import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart'; import 'theming/light_app_theme.dart';
import 'utils/app_paths.dart'; import 'utils/app_paths.dart';
import 'utils/debouncer.dart';
import 'utils/downloads/download_manager.dart'; import 'utils/downloads/download_manager.dart';
import 'view/login/account_loading_screen.dart'; import 'view/login/account_loading_screen.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
@@ -119,11 +121,7 @@ void _installErrorHandlers() {
}; };
} }
// Everything runs inside the zone so every package:http client created by the Future<void> main() async {
// app (Talk, avatars, image caches) goes through DemoHttpClient.
Future<void> main() => http.runWithClient(_main, DemoHttpClient.new);
Future<void> _main() async {
log('MarianumMobile started'); log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SessionManager().markUiEngine(); SessionManager().markUiEngine();
@@ -169,6 +167,7 @@ Future<void> _main() async {
report: false, report: false,
), ),
_startupStep('share intent', ShareIntentListener.instance.initialize), _startupStep('share intent', ShareIntentListener.instance.initialize),
_startupStep('guardian link', GuardianLinkListener.instance.initialize),
]; ];
log('starting app initialisation...'); log('starting app initialisation...');
@@ -254,7 +253,7 @@ Future<void> _main() async {
AvatarDiskCache.instance.warmUp(); AvatarDiskCache.instance.warmUp();
// Created eagerly so the endpoint is configured before anything below can // Created eagerly so the endpoint is configured before anything below can
// issue a request (the timetable bloc loads on creation). // issue a request (the primary timetable bloc loads on creation).
final settingsCubit = SettingsCubit(storage: AccountStorage.global); final settingsCubit = SettingsCubit(storage: AccountStorage.global);
_syncMarianumConnectEndpoint(settingsCubit.state); _syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint); settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
@@ -323,6 +322,8 @@ class _MainState extends State<Main> {
Jiffy.setLocale('de'); Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status; _lastStatus = context.read<AccountBloc>().state.status;
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
GuardianLinkListener.pending.addListener(_onGuardianLink);
SessionManager().waitForLoad().then((session) async { SessionManager().waitForLoad().then((session) async {
if (!mounted) return; if (!mounted) return;
final accountBloc = context.read<AccountBloc>(); final accountBloc = context.read<AccountBloc>();
@@ -337,6 +338,7 @@ class _MainState extends State<Main> {
if (!mounted) return; if (!mounted) return;
accountBloc.activated(account?.id); accountBloc.activated(account?.id);
_scheduleSessionValidation(accountBloc); _scheduleSessionValidation(accountBloc);
unawaited(_onGuardianLink());
}); });
} }
@@ -351,6 +353,7 @@ class _MainState extends State<Main> {
if (!mounted || capabilitiesCubit.isClosed) return; if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit); _syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit); _applyRoleDefaults(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}), }),
); );
unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().load()); unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().load());
@@ -369,11 +372,21 @@ class _MainState extends State<Main> {
InfoDialog.show(overlayContext, notice, title: 'Abgemeldet'); InfoDialog.show(overlayContext, notice, title: 'Abgemeldet');
} }
/// Warms the core caches (timetable, chat list, files root) in the /// Waits for the post-login splash so the dialog never covers it; the
/// background so the first screen render hits populated data. /// splash's completion calls this again.
void _promptGuardianNotifications() {
if (_showPostLoginSplash) return;
final overlayContext = AppRoutes.overlayContext;
if (overlayContext == null) return;
unawaited(maybePromptGuardianLoginNotifications(overlayContext));
}
/// Warms the chat list and files root in the background so the first screen
/// render hits populated data. The timetable needs no warm-up:
/// PrimaryTimetableScope creates a freshly loading bloc per account.
void _prefetchBaseData(BuildContext context) { void _prefetchBaseData(BuildContext context) {
context.read<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().refresh(silent: true)); unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) { if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing()); unawaited(ListFilesCache.prefetchRootListing());
} }
@@ -406,12 +419,40 @@ class _MainState extends State<Main> {
) { ) {
if (AppModule.applyTeacherDefaults( if (AppModule.applyTeacherDefaults(
settings.val().modulesSettings, settings.val().modulesSettings,
isTeacher: capabilities.isTeacher, isTeacher: capabilities.state.role == UserRole.teacher,
)) { )) {
settings.val(write: true); settings.val(write: true);
} }
} }
@override
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
GuardianLinkListener.pending.removeListener(_onGuardianLink);
super.dispose();
}
/// A guardian login link tapped while signed in belongs to an "add account"
/// flow (the app may have been killed meanwhile): reopen the login for it.
Future<void> _onGuardianLink() async {
if (GuardianLinkListener.pending.value == null || !mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
await SessionLifecycle.beginAddAccount();
accountBloc.setStatus(AccountStatus.addingAccount);
}
void _onUnauthorized() {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
Debouncer.throttle(
'sessionUnauthorized',
const Duration(seconds: 30),
() => _scheduleSessionValidation(accountBloc),
);
}
/// Background credential check: a 401 means the password was rotated /// Background credential check: a 401 means the password was rotated
/// server-side, so the validator signs the account out and the app moves /// server-side, so the validator signs the account out and the app moves
/// on to another stored account or the login screen. /// on to another stored account or the login screen.
@@ -570,8 +611,10 @@ class _MainState extends State<Main> {
if (_showPostLoginSplash) if (_showPostLoginSplash)
PostLoginSplash( PostLoginSplash(
key: const ValueKey('post-login-splash'), key: const ValueKey('post-login-splash'),
onComplete: () => onComplete: () {
setState(() => _showPostLoginSplash = false), setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
), ),
], ],
); );
-3
View File
@@ -5,9 +5,6 @@ class Endpoint {
Endpoint({required this.domain, this.path = ''}); Endpoint({required this.domain, this.path = ''});
String full() => domain + path; String full() => domain + path;
/// `https://` + [full], without a trailing slash.
String origin() => 'https://${full()}';
} }
class EndpointData { class EndpointData {
+2 -1
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart';
@@ -52,7 +53,7 @@ abstract final class SessionWipe {
/// unregister it). /// unregister it).
static Future<void> accountLeft() async { static Future<void> accountLeft() async {
await _stepAsync('download files', () async { await _stepAsync('download files', () async {
final dir = await DownloadManager.downloadsDirectory(); final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (dir.existsSync()) await dir.delete(recursive: true); if (dir.existsSync()) await dir.delete(recursive: true);
}); });
await _stepAsync( await _stepAsync(
@@ -15,7 +15,7 @@ import '../widget/info_dialog.dart';
import 'notification_tasks.dart'; import 'notification_tasks.dart';
/// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are /// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are
/// handled directly by `pushOnBackgroundMessage`; this class /// handled directly by [PushMessageHandler.onBackgroundMessage]; this class
/// covers the foreground and app-opened paths where a [BuildContext] is /// covers the foreground and app-opened paths where a [BuildContext] is
/// available. /// available.
class NotificationController { class NotificationController {
+33 -1
View File
@@ -7,9 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart'; import '../push/chat_thread_store.dart';
import '../push/push_renderer.dart';
import '../push/push_target.dart'; import '../push/push_target.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'notification_service.dart'; import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
@@ -27,13 +31,17 @@ class NotificationTasks {
} }
} }
/// Per-chat tag scheme — canonical definition lives beside the stacked
/// notification id in chat_thread_store.dart.
static String chatTag(String chatToken) => chatNotificationTag(chatToken);
/// Removes tray notifications belonging to [chatToken]. Eraser handles /// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids /// iOS (where the plugin's `getActiveNotifications` returns null ids
/// for FCM posts and can't cancel them); the local-notifications sweep /// for FCM posts and can't cancel them); the local-notifications sweep
/// handles Android and acts as a fallback while Eraser's native side /// handles Android and acts as a fallback while Eraser's native side
/// isn't built in yet. /// isn't built in yet.
static Future<void> clearNotificationsForChat(String chatToken) async { static Future<void> clearNotificationsForChat(String chatToken) async {
final tag = chatNotificationTag(chatToken); final tag = chatTag(chatToken);
try { try {
await Eraser.clearAppNotificationsByTag(tag); await Eraser.clearAppNotificationsByTag(tag);
} on MissingPluginException { } on MissingPluginException {
@@ -67,6 +75,7 @@ class NotificationTasks {
/// even if the user has already left. /// even if the user has already left.
static void updateProviders(BuildContext context) { static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
context.read<ParentLettersBloc>().refresh(silent: true);
} }
/// [title] is the notification title, when the tap came with one. /// [title] is the notification title, when the tap came with one.
@@ -76,6 +85,13 @@ class NotificationTasks {
String? title, String? title,
}) { }) {
switch (target) { switch (target) {
case ParentLetterTarget(:final letterId):
if (AppModule.isAvailableFor(
Modules.parentLetters,
SessionManager().current,
)) {
AppRoutes.openParentLetter(context, id: letterId);
}
case NewsletterTarget(:final newsletterId): case NewsletterTarget(:final newsletterId):
AppRoutes.openNewsletterById(context, id: newsletterId, title: title); AppRoutes.openNewsletterById(context, id: newsletterId, title: title);
case ChatTarget(:final chatToken): case ChatTarget(:final chatToken):
@@ -83,10 +99,26 @@ class NotificationTasks {
} }
} }
/// Removes the letter's tray notification (Android; iOS alerts are posted by
/// the system and cannot be addressed by id).
static Future<void> clearParentLetterNotification(String letterId) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: PushRenderer.parentLetterNotificationId(letterId),
);
await refreshBadge();
} on Object catch (e) {
log('Parent letter notification cleanup failed: $e');
}
}
/// Switches to the Talk tab. If [chatToken] is provided, also schedules /// Switches to the Talk tab. If [chatToken] is provided, also schedules
/// the matching chat to be opened automatically once the chat list view /// the matching chat to be opened automatically once the chat list view
/// resolves the token (handled inside [ChatList]). /// resolves the token (handled inside [ChatList]).
static void navigateToTalk(BuildContext context, {String? chatToken}) { static void navigateToTalk(BuildContext context, {String? chatToken}) {
if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) {
return;
}
if (chatToken != null && chatToken.isNotEmpty) { if (chatToken != null && chatToken.isNotEmpty) {
AppRoutes.openChatByToken(context, chatToken); AppRoutes.openChatByToken(context, chatToken);
} else { } else {
+18 -11
View File
@@ -6,20 +6,13 @@ import 'package:localstore/localstore.dart';
/// notification, small enough to stay cheap in the background isolate. /// notification, small enough to stay cheap in the background isolate.
const int kChatThreadCap = 15; const int kChatThreadCap = 15;
/// Canonical per-chat notification tag. /// Canonical per-chat notification tag (kept in sync with
/// `NotificationTasks.chatTag`, which delegates here).
String chatNotificationTag(String chatToken) => 'talk_$chatToken'; String chatNotificationTag(String chatToken) => 'talk_$chatToken';
/// Deterministic non-negative 31-bit notification id per chat, so every /// Deterministic non-negative 31-bit notification id per chat, so every
/// message of the same conversation updates ONE stacked notification instead /// message of the same conversation updates ONE stacked notification instead
/// of adding a new card per push. /// of adding a new card per push.
final _fileSafeToken = RegExp(r'^[A-Za-z0-9_-]+$');
/// File-/doc-safe name for a chat token: normally URL-safe and used verbatim,
/// anything else is base64url-encoded so exotic ids can't escape a directory.
String fileSafeToken(String chatToken) => _fileSafeToken.hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
int stableChatNotificationId(String chatToken) { int stableChatNotificationId(String chatToken) {
var hash = 0; var hash = 0;
for (final unit in chatToken.codeUnits) { for (final unit in chatToken.codeUnits) {
@@ -135,8 +128,12 @@ class ChatThreadStore {
ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance; ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance;
/// Localstore doc ids become file names. /// Localstore doc ids become file names — tokens are normally URL-safe and
static String docIdForToken(String chatToken) => fileSafeToken(chatToken); /// used verbatim, anything else is base64url-encoded.
static String docIdForToken(String chatToken) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
Future<List<ThreadMessage>> messages(String chatToken) async { Future<List<ThreadMessage>> messages(String chatToken) async {
final data = await _doc(chatToken).get(); final data = await _doc(chatToken).get();
@@ -148,6 +145,16 @@ class ChatThreadStore {
.toList(); .toList();
} }
/// Appends [message] and returns the updated (capped) history.
Future<List<ThreadMessage>> append(
String chatToken,
ThreadMessage message,
) async {
final updated = appendThreadMessage(await messages(chatToken), message);
await _write(chatToken, updated);
return updated;
}
/// Appends [message], but restarts the thread first when the chat's previous /// Appends [message], but restarts the thread first when the chat's previous
/// notification is no longer active ([isActive] == false — dismissed/read). /// notification is no longer active ([isActive] == false — dismissed/read).
/// See [threadAfterIncoming] for the tri-state semantics. /// See [threadAfterIncoming] for the tri-state semantics.
+71
View File
@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../utils/random_id.dart';
import 'push_device_info.dart';
import 'push_secure_storage.dart';
/// Push registration for accounts without Nextcloud (guardians): the device
/// registers straight with MarianumConnect and only receives its direct
/// pushes (newsletter, widget refresh, later guardian messages). Nextcloud
/// normally supplies the device identifier; here a random one is kept per
/// install.
class DirectPushRegistration {
static const String registrationType = 'direct';
static const _deviceIdentifierKey = 'push_direct_device_identifier';
final FlutterSecureStorageLike _storage;
const DirectPushRegistration({
FlutterSecureStorageLike storage = const PushSecureStorage(),
}) : _storage = storage;
Future<bool> register() async {
try {
final (fcmToken, appVersion, identifier) = await (
FirebaseMessaging.instance.getToken(),
pushAppVersion(),
deviceIdentifier(),
).wait;
if (fcmToken == null || fcmToken.isEmpty) {
log('Push (direct): no FCM token, skipping registration');
return false;
}
await PushDeviceRegister().run(
deviceIdentifier: identifier,
pushToken: fcmToken,
platform: pushPlatform,
registrationType: registrationType,
appVersion: appVersion,
);
return true;
} on Object catch (e) {
log('Push (direct): registration failed: $e');
return false;
}
}
Future<void> unregister() async {
final identifier = await _storage.read(key: _deviceIdentifierKey);
if (identifier == null) return;
try {
await PushDeviceUnregister().run(deviceIdentifier: identifier);
} on Object catch (e) {
log('Push (direct): unregister failed: $e');
}
await _storage.delete(key: _deviceIdentifierKey);
}
/// Stable per install until [unregister], so re-registrations upsert the
/// same server row instead of piling up devices.
Future<String> deviceIdentifier() async {
final stored = await _storage.read(key: _deviceIdentifierKey);
if (stored != null && stored.isNotEmpty) return stored;
final fresh = randomHexId();
await _storage.write(key: _deviceIdentifierKey, value: fresh);
return fresh;
}
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:developer';
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../storage/notification_settings.dart';
import '../widget/confirm_dialog.dart';
import 'push_registration.dart';
const _talkExplanation =
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
'sie, um Push-Nachrichten zu erhalten.';
const _talkDeclinedNote =
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
'deines Geräts nachträglich aktivieren.';
const _parentLetterExplanation =
'Damit du keine Elternbriefe der Schule verpasst, fragen wir dich gleich '
'nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere sie, um '
'Push-Nachrichten zu erhalten.';
const _parentLetterDeclinedNote =
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
'Elternbriefen. Du kannst sie jederzeit in den Systemeinstellungen deines '
'Geräts nachträglich aktivieren.';
/// The occasions with a guided notification-permission flow. Each runs at
/// most once per install, guarded by its own flag in [NotificationSettings].
enum _PermissionPrompt {
talkVisit(_talkExplanation, _talkDeclinedNote),
guardianLogin(
_parentLetterExplanation,
_parentLetterDeclinedNote,
module: Modules.parentLetters,
spentOnDisplay: true,
),
parentLettersVisit(
_parentLetterExplanation,
_parentLetterDeclinedNote,
module: Modules.parentLetters,
);
const _PermissionPrompt(
this.explanation,
this.declinedNote, {
this.module,
this.spentOnDisplay = false,
});
final String explanation;
final String declinedNote;
/// Only sessions that have this module are asked.
final Modules? module;
/// Spends the one-shot as soon as the explanation is displayed, so a
/// dismissed dialog does not come back on every app start.
final bool spentOnDisplay;
bool wasShown(NotificationSettings settings) => switch (this) {
talkVisit => settings.talkPermissionPromptShown,
guardianLogin => settings.guardianLoginPromptShown,
parentLettersVisit => settings.parentLettersPromptShown,
};
void markShown(NotificationSettings settings) {
switch (this) {
case talkVisit:
settings.talkPermissionPromptShown = true;
case guardianLogin:
settings.guardianLoginPromptShown = true;
case parentLettersVisit:
settings.parentLettersPromptShown = true;
}
}
}
/// Shows the one-time notification-permission flow on the first Talk visit.
///
/// The OS prompt is deliberately kept out of the cold-start path (younger users
/// decline it reflexively before ever seeing why they'd want it). Instead, the
/// first time Talk is opened we explain the request, then trigger the OS prompt,
/// and — if declined — offer a shortcut to the system settings.
///
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
Future<void> maybePromptTalkNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.talkVisit);
/// Accounts that receive parent letters never reach the Talk flow, so they
/// are asked once right after signing in.
Future<void> maybePromptGuardianLoginNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.guardianLogin);
/// Second chance with context: explains the request once more on the first
/// visit of the parent letters when the permission is still missing.
Future<void> maybePromptParentLetterNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.parentLettersVisit);
bool _promptInFlight = false;
Future<void> _maybePrompt(
BuildContext context,
_PermissionPrompt prompt,
) async {
final module = prompt.module;
if (module != null &&
!AppModule.isAvailableFor(module, SessionManager().current)) {
return;
}
final settings = context.read<SettingsCubit>();
final notificationSettings = settings.val().notificationSettings;
// Already handled once, or the user opted out of push entirely.
if (prompt.wasShown(notificationSettings)) return;
if (!notificationSettings.enabled) return;
// Capabilities may still be loading on a fresh cold start; retry on the next
// occasion instead of burning the one-shot flag.
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
if (_promptInFlight) return;
_promptInFlight = true;
try {
// Users who already granted the permission: register silently and mark the
// prompt as handled without showing any dialog.
if (await PushRegistration.isOsPermissionGranted()) {
prompt.markShown(settings.val(write: true).notificationSettings);
unawaited(PushRegistration().register());
return;
}
if (!context.mounted) return;
if (prompt.spentOnDisplay) {
prompt.markShown(settings.val(write: true).notificationSettings);
}
// The OS prompt (and its "declined" follow-up) outlive this dialog, so
// hold on to them: releasing the guard at the dialog's close would let a
// second occasion stack another dialog over the pending OS prompt.
Future<void>? permissionRequest;
await showDialog<void>(
context: context,
builder: ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content: prompt.explanation,
confirmButton: 'Weiter',
cancelButton: null,
onConfirm: () =>
permissionRequest = _requestPermission(context, settings, prompt),
).build,
);
await permissionRequest;
} finally {
_promptInFlight = false;
}
}
Future<void> _requestPermission(
BuildContext context,
SettingsCubit settings,
_PermissionPrompt prompt,
) async {
final granted = await PushRegistration.requestOsPermission();
// Mark handled regardless of the outcome — the user can re-enable later via
// the system settings; we don't want to prompt again on the next occasion.
prompt.markShown(settings.val(write: true).notificationSettings);
if (granted) {
unawaited(PushRegistration().register());
return;
}
log('Push: notification permission declined on ${prompt.name}');
if (!context.mounted) return;
ConfirmDialog(
icon: Icons.notifications_off_outlined,
title: 'Benachrichtigungen deaktiviert',
content: prompt.declinedNote,
confirmButton: 'Einstellungen öffnen',
cancelButton: 'Später',
onConfirm: () =>
AppSettings.openAppSettings(type: AppSettingsType.notification),
).asDialog(context);
}
+13 -4
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:ui'; import 'dart:ui';
@@ -12,7 +13,6 @@ import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_renderer.dart'; import 'push_renderer.dart';
import 'push_target.dart';
/// Notification action identifiers shared between the renderer (which attaches /// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them). /// the actions) and the response handlers (which dispatch them).
@@ -93,7 +93,8 @@ class PushActions {
// the mark-read action does. Best effort: a failure here must not // the mark-read action does. Best effort: a failure here must not
// fail the already-delivered reply. // fail the already-delivered reply.
await markRead(chatToken); await markRead(chatToken);
} else { }
if (!result.ok) {
// Never swallow the typed message: surface the failure (and the // Never swallow the typed message: surface the failure (and the
// undelivered text) as its own notification. The nid mapping stays // undelivered text) as its own notification. The nid mapping stays
// alive — the thread notification is still in the tray. // alive — the thread notification is still in the tray.
@@ -286,6 +287,14 @@ class PushActions {
return raw == null ? null : int.tryParse(raw); return raw == null ? null : int.tryParse(raw);
} }
static String? _payloadField(String? payload, String key) => static String? _payloadField(String? payload, String key) {
decodeNotificationPayload(payload)?[key]?.toString(); if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
} }
+7 -2
View File
@@ -9,7 +9,6 @@ import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../api/marianumcloud/nextcloud_ocs.dart'; import '../api/marianumcloud/nextcloud_ocs.dart';
import 'chat_thread_store.dart';
/// Result of an avatar lookup at render time: [icon] when bytes are available /// Result of an avatar lookup at render time: [icon] when bytes are available
/// within the fetch budget, otherwise [late] carries the still-running fetch /// within the fetch budget, otherwise [late] carries the still-running fetch
@@ -103,7 +102,13 @@ class PushAvatarStore {
}, },
); );
static String fileNameForToken(String token) => fileSafeToken(token); /// File-safe cache name for a chat token. Tokens are normally URL-safe
/// already and used verbatim; anything else is base64url-encoded so exotic
/// ids can never escape the cache directory.
static String fileNameForToken(String token) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token)
? token
: base64Url.encode(utf8.encode(token)).replaceAll('=', '');
/// Whether a cache file written at [modified] is still fresh at [now]. /// Whether a cache file written at [modified] is still fresh at [now].
static bool isFresh(DateTime modified, DateTime now) => static bool isFresh(DateTime modified, DateTime now) =>
+2
View File
@@ -101,6 +101,7 @@ class PushMessageHandler {
case PushKind.connect: case PushKind.connect:
await _handleConnect( await _handleConnect(
message, message,
foreground: foreground,
notificationsEnabled: notificationsEnabled, notificationsEnabled: notificationsEnabled,
); );
break; break;
@@ -138,6 +139,7 @@ class PushMessageHandler {
Future<void> _handleConnect( Future<void> _handleConnect(
RemoteMessage message, { RemoteMessage message, {
required bool foreground,
required bool notificationsEnabled, required bool notificationsEnabled,
}) async { }) async {
// Connect pushes carry no silent side effects, so nothing to do when the // Connect pushes carry no silent side effects, so nothing to do when the
+12 -5
View File
@@ -13,6 +13,7 @@ import '../api/marianumconnect/queries/push_device_unregister/push_device_unregi
import '../model/endpoint_data.dart'; import '../model/endpoint_data.dart';
import '../session/nextcloud_credentials.dart'; import '../session/nextcloud_credentials.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
import 'direct_push_registration.dart';
import 'nextcloud_push_api.dart'; import 'nextcloud_push_api.dart';
import 'push_device_info.dart'; import 'push_device_info.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
@@ -60,11 +61,14 @@ class PushRegistration {
/// Nextcloud origin the registration targets (full origin, no trailing /// Nextcloud origin the registration targets (full origin, no trailing
/// slash) — persisted alongside the registration to detect endpoint changes. /// slash) — persisted alongside the registration to detect endpoint changes.
String get currentNcBaseUrl => EndpointData().nextcloud().origin(); String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
NextcloudCredentials? get _nextcloudOrNull => NextcloudCredentials? get _nextcloudOrNull =>
SessionManager().current?.nextcloud; SessionManager().current?.nextcloud;
/// Channel for sessions without Nextcloud; see [DirectPushRegistration].
static const _direct = DirectPushRegistration();
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering. /// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async { Future<void> ensureAppPassword() async {
@@ -113,7 +117,7 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result). /// fire-and-forget (and simply ignore the result).
Future<bool> register() async { Future<bool> register() async {
if (DemoMode.active) return false; if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return false; if (_nextcloudOrNull == null) return _direct.register();
final epoch = SessionManager().sessionEpoch; final epoch = SessionManager().sessionEpoch;
final String? fcmToken; final String? fcmToken;
try { try {
@@ -258,9 +262,10 @@ class PushRegistration {
/// Flutter engine. Best-effort — a failure here must not abort registration. /// Flutter engine. Best-effort — a failure here must not abort registration.
Future<void> _persistNativeAuthContext() async { Future<void> _persistNativeAuthContext() async {
try { try {
final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext( await _store.saveNativeAuthContext(
username: SessionManager().requireNextcloud().username, username: SessionManager().requireNextcloud().username,
baseUrl: currentNcBaseUrl, baseUrl: 'https://${endpoint.full()}',
); );
} on Object catch (e) { } on Object catch (e) {
log('Push: could not persist native auth context: $e'); log('Push: could not persist native auth context: $e');
@@ -429,7 +434,8 @@ class PushRegistration {
/// Stops pushes for the active account before another one takes over. The /// Stops pushes for the active account before another one takes over. The
/// app passwords stay valid so switching back can re-register silently. /// app passwords stay valid so switching back can re-register silently.
Future<void> deactivate() async { Future<void> deactivate() async {
if (DemoMode.active || _nextcloudOrNull == null) return; if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister(); await unregister();
} }
@@ -438,7 +444,8 @@ class PushRegistration {
/// is called with), then clear them locally. Ordered so the proxy stops /// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone. /// pushing before credentials are gone.
Future<void> logoutCleanup() async { Future<void> logoutCleanup() async {
if (DemoMode.active || _nextcloudOrNull == null) return; if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister(); await unregister();
try { try {
await DeleteAppPassword().run(); await DeleteAppPassword().run();
+51 -31
View File
@@ -12,6 +12,7 @@ import 'nid_store.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_avatar.dart'; import 'push_avatar.dart';
import 'push_subject.dart'; import 'push_subject.dart';
import 'push_target.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local /// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE /// notifications. Talk messages of one chat stack into a SINGLE
@@ -23,6 +24,8 @@ class PushRenderer {
static const talkChannelName = 'Talk-Nachrichten'; static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general'; static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen'; static const generalChannelName = 'Benachrichtigungen';
static const parentLettersChannelId = 'parent_letters';
static const parentLettersChannelName = 'Elternbriefe';
static const String iosTalkCategory = 'TALK_MESSAGE'; static const String iosTalkCategory = 'TALK_MESSAGE';
@@ -67,6 +70,14 @@ class PushRenderer {
description: 'Allgemeine Benachrichtigungen', description: 'Allgemeine Benachrichtigungen',
), ),
); );
await android.createNotificationChannel(
const AndroidNotificationChannel(
parentLettersChannelId,
parentLettersChannelName,
description: 'Neue Elternbriefe und Antworten der Schule',
importance: Importance.high,
),
);
} }
/// Renders a decrypted Nextcloud push subject. /// Renders a decrypted Nextcloud push subject.
@@ -203,10 +214,16 @@ class PushRenderer {
messages: styleMessages, messages: styleMessages,
); );
final androidDetails = _talkAndroidDetails( final androidDetails = AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag, tag: tag,
style: messagingStyle,
silent: !alert, silent: !alert,
styleInformation: messagingStyle,
actions: _talkActions, actions: _talkActions,
); );
@@ -241,11 +258,17 @@ class PushRenderer {
title: senderName, title: senderName,
body: messageText, body: messageText,
notificationDetails: NotificationDetails( notificationDetails: NotificationDetails(
android: _talkAndroidDetails( android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag, tag: tag,
// Single sender ⇒ no conversationTitle (1:1 convention): the sender // Single sender ⇒ no conversationTitle (1:1 convention): the sender
// Person is the header, a title would repeat the name per line. // Person is the header, a title would repeat the name per line.
style: MessagingStyleInformation( styleInformation: MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'), const Person(key: 'self', name: 'Ich'),
groupConversation: false, groupConversation: false,
messages: [ messages: [
@@ -259,25 +282,6 @@ class PushRenderer {
); );
} }
static AndroidNotificationDetails _talkAndroidDetails({
required String tag,
required StyleInformation style,
AndroidNotificationCategory? category = AndroidNotificationCategory.message,
bool silent = false,
List<AndroidNotificationAction>? actions,
}) => AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: category,
color: _accentColor,
tag: tag,
silent: silent,
styleInformation: style,
actions: actions,
);
// Both actions keep cancelNotification: true (the default): the plugin's // Both actions keep cancelNotification: true (the default): the plugin's
// Java receiver then removes the card NATIVELY the moment the action fires. // Java receiver then removes the card NATIVELY the moment the action fires.
// The reply action must not rely on our Dart-side cancel instead — MIUI/ // The reply action must not rely on our Dart-side cancel instead — MIUI/
@@ -313,10 +317,14 @@ class PushRenderer {
title: title, title: title,
body: body, body: body,
notificationDetails: NotificationDetails( notificationDetails: NotificationDetails(
android: _talkAndroidDetails( android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
tag: 'talk_error_$chatToken', tag: 'talk_error_$chatToken',
style: BigTextStyleInformation(body), styleInformation: BigTextStyleInformation(body),
category: null,
), ),
iOS: const DarwinNotificationDetails(), iOS: const DarwinNotificationDetails(),
), ),
@@ -353,23 +361,35 @@ class PushRenderer {
required String body, required String body,
Map<String, String>? data, Map<String, String>? data,
}) async { }) async {
final id = _fallbackId('$title$body'); final parentLetterId = data?[parentLetterIdKey];
const androidDetails = AndroidNotificationDetails( final isParentLetter =
generalChannelId, data?['type'] == parentLetterPushType &&
generalChannelName, parentLetterId != null &&
parentLetterId.isNotEmpty;
// One notification per letter: a reply replaces the letter's earlier one.
final id = isParentLetter
? parentLetterNotificationId(parentLetterId)
: _fallbackId('$title$body');
final androidDetails = AndroidNotificationDetails(
isParentLetter ? parentLettersChannelId : generalChannelId,
isParentLetter ? parentLettersChannelName : generalChannelName,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
color: _accentColor, color: _accentColor,
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
); );
await _plugin.show( await _plugin.show(
id: id, id: id,
title: title, title: title,
body: body, body: body,
notificationDetails: const NotificationDetails(android: androidDetails), notificationDetails: NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data), payload: data == null ? null : jsonEncode(data),
); );
} }
static int parentLetterNotificationId(String letterId) =>
stableChatNotificationId('parent-letter:$letterId');
String _payload({required String? chatToken, required int nid}) => String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid}); jsonEncode({'chatToken': ?chatToken, 'nid': nid});
+11 -1
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -24,7 +25,7 @@ class PushTapRouter {
PushActions.handleBackgroundResponse(response); PushActions.handleBackgroundResponse(response);
return; return;
} }
final map = decodeNotificationPayload(response.payload); final map = _payloadMap(response.payload);
if (map == null) return; if (map == null) return;
final target = resolvePushTarget(map); final target = resolvePushTarget(map);
if (target != null) pendingTarget.value = target; if (target != null) pendingTarget.value = target;
@@ -49,4 +50,13 @@ class PushTapRouter {
log('Reading the notification launch details failed: $e'); log('Reading the notification launch details failed: $e');
} }
} }
static Map<String, dynamic>? _payloadMap(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
} }
+12 -12
View File
@@ -1,21 +1,18 @@
import 'dart:convert'; /// `type` of the visible Connect push for a parent letter. Its data carries
/// the letter under [parentLetterIdKey].
/// Decodes the JSON payload of a locally rendered notification; null when const String parentLetterPushType = 'parent-letter';
/// absent or malformed. const String parentLetterIdKey = 'parentLetterId';
Map<String, dynamic>? decodeNotificationPayload(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
/// Where a tapped notification leads. /// Where a tapped notification leads.
sealed class PushTarget { sealed class PushTarget {
const PushTarget(); const PushTarget();
} }
class ParentLetterTarget extends PushTarget {
final String letterId;
const ParentLetterTarget(this.letterId);
}
class NewsletterTarget extends PushTarget { class NewsletterTarget extends PushTarget {
final String newsletterId; final String newsletterId;
const NewsletterTarget(this.newsletterId); const NewsletterTarget(this.newsletterId);
@@ -35,6 +32,9 @@ PushTarget? resolvePushTarget(Map<String, dynamic> data) {
return value is String && value.isNotEmpty ? value : null; return value is String && value.isNotEmpty ? value : null;
} }
if (value(parentLetterIdKey) case final letterId?) {
return ParentLetterTarget(letterId);
}
if (value('newsletterId') case final newsletterId?) { if (value('newsletterId') case final newsletterId?) {
return NewsletterTarget(newsletterId); return NewsletterTarget(newsletterId);
} }
+39 -34
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,13 +20,13 @@ import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../utils/url_opener.dart'; import '../utils/url_opener.dart';
import '../view/login/nextcloud_login_flow_page.dart'; import '../view/login/nextcloud_login_flow_page.dart';
import '../view/pages/files/files.dart'; import '../view/pages/files/files.dart';
import '../view/pages/files/files_upload_dialog.dart';
import '../view/pages/files/sharing/sharee_picker_page.dart'; import '../view/pages/files/sharing/sharee_picker_page.dart';
import '../view/pages/foreign_timetable/element_picker_page.dart'; import '../view/pages/foreign_timetable/element_picker_page.dart';
import '../view/pages/marianum_message/marianum_message_view.dart'; import '../view/pages/marianum_message/marianum_message_view.dart';
import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/feedback/feedback_dialog.dart';
import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart'; import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/diagnostics_page.dart'; import '../view/pages/settings/diagnostics_page.dart';
import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart';
@@ -147,17 +146,19 @@ class AppRoutes {
/// the selected element (or null if dismissed). The timetable view renders /// the selected element (or null if dismissed). The timetable view renders
/// the chosen plan inline. Gated behind the `viewForeignTimetables` /// the chosen plan inline. Gated behind the `viewForeignTimetables`
/// capability at the call site. /// capability at the call site.
static Future<TimetableElementRef?> openElementPicker(BuildContext context) => static Future<TimetableElementRef?> openElementPicker(
_pushForResult(context, const ElementPickerPage());
// pushScreen casts its internal MaterialPageRoute to `Route<T>`, which blows
// up for a concrete (record) T — so push untyped (T = dynamic, like every
// other caller) and cast the popped result ourselves.
static Future<T?> _pushForResult<T>(
BuildContext context, BuildContext context,
Widget screen, ) async {
) async => // pushScreen casts its internal MaterialPageRoute to `Route<T>`, which
(await pushScreen(context, withNavBar: false, screen: screen)) as T?; // blows up for a concrete (record) T — so push untyped (T = dynamic, like
// every other caller) and cast the popped result ourselves.
final result = await pushScreen(
context,
withNavBar: false,
screen: const ElementPickerPage(),
);
return result as TimetableElementRef?;
}
/// Opens the user/group picker for sharing a file and resolves to the chosen /// Opens the user/group picker for sharing a file and resolves to the chosen
/// recipient (or null if dismissed). Which kinds are offered is gated by the /// recipient (or null if dismissed). Which kinds are offered is gated by the
@@ -166,10 +167,19 @@ class AppRoutes {
BuildContext context, { BuildContext context, {
required bool allowUsers, required bool allowUsers,
required bool allowGroups, required bool allowGroups,
}) => _pushForResult( }) async {
context, // Push untyped (T = dynamic) and cast the popped result ourselves —
ShareePickerPage(allowUsers: allowUsers, allowGroups: allowGroups), // pushScreen can't carry a concrete non-Widget T (see openElementPicker).
); final result = await pushScreen(
context,
withNavBar: false,
screen: ShareePickerPage(
allowUsers: allowUsers,
allowGroups: allowGroups,
),
);
return result as ShareeRef?;
}
static void openMarianumMessage( static void openMarianumMessage(
BuildContext context, BuildContext context,
@@ -199,6 +209,12 @@ class AppRoutes {
); );
} }
/// Opens a parent letter by id — from the inbox as well as from push deep
/// links, where only the id is known.
static void openParentLetter(BuildContext context, {required String id}) {
pushScreen(context, withNavBar: false, screen: ParentLetterView(id: id));
}
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail /// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
/// screen. Used for deep links from outside the ticker module — inside the /// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead. /// module pages open in-place instead.
@@ -281,23 +297,6 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
} }
static Future<void> openFilesUpload(
BuildContext context, {
required List<String> filePaths,
required String remotePath,
required void Function(List<String> uploadedFilePaths) onUploadFinished,
bool uniqueNames = false,
}) => pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: filePaths,
remotePath: remotePath,
onUploadFinished: onUploadFinished,
uniqueNames: uniqueNames,
),
);
static Future<void> openCacheView(BuildContext context) => static Future<void> openCacheView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const CacheView()); pushScreen(context, withNavBar: false, screen: const CacheView());
@@ -460,7 +459,13 @@ class AppRoutes {
static GetRoomResponseObject? _findRoomByToken( static GetRoomResponseObject? _findRoomByToken(
GetRoomResponse? rooms, GetRoomResponse? rooms,
String token, String token,
) => rooms?.data.firstWhereOrNull((room) => room.token == token); ) {
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room;
}
return null;
}
/// Pushes a module from the "Mehr" tab list. Modules already in the bottom /// Pushes a module from the "Mehr" tab list. Modules already in the bottom
/// bar are switched to via [goToTab] instead. /// bar are switched to via [goToTab] instead.
+4 -1
View File
@@ -9,7 +9,7 @@ class AccountEntry {
final String id; final String id;
final String kind; final String kind;
/// Login name — the identity of the account. /// Username or e-mail — the identity of the account.
final String label; final String label;
/// Real name as known to the server; null until it was loaded once. /// Real name as known to the server; null until it was loaded once.
@@ -31,6 +31,8 @@ class AccountEntry {
this.lastUsed = 0, this.lastUsed = 0,
}); });
bool get isGuardian => kind == SessionKeys.kindGuardian;
AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry( AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry(
id: id, id: id,
kind: kind, kind: kind,
@@ -190,6 +192,7 @@ class AccountIndex {
(String kind, String label) identityOf(Session session) => switch (session) { (String kind, String label) identityOf(Session session) => switch (session) {
CredentialSession(:final username) => (SessionKeys.kindCredential, username), CredentialSession(:final username) => (SessionKeys.kindCredential, username),
GuardianSession(:final email) => (SessionKeys.kindGuardian, email),
}; };
/// Keychain fields of [session] including the app passwords, as stored in an /// Keychain fields of [session] including the app passwords, as stored in an
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more