Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8fed24bb | ||
|
|
b413fe05de | ||
|
|
38a651f916 | ||
|
|
f98a6979fb | ||
|
|
a71f4f379b | ||
|
|
84098af7e2 | ||
|
|
630497abdd | ||
|
|
2423c1a75e | ||
|
|
67c935c05b |
No files matched your search
@@ -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.
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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}) {
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+2
-1
@@ -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;
|
||||||
|
|||||||
+18
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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('=', '');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/// A sign-in link from the guardian login mail
|
||||||
|
/// (`https://<connect-host>/app/guardian-login?rid=…<=…`).
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}',
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user