Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32be67426c | |||
| b957189fd3 | |||
| 624c5512a6 | |||
| 3493cf8617 | |||
| 75080a2c49 | |||
| f3cd7896d9 | |||
| 101e7c909c | |||
| 429f1e6f96 | |||
| a0c55a811c | |||
| e625216a90 | |||
| 8128cede21 | |||
| e5f7cf0176 | |||
| e349d667d4 | |||
| 7c1f5c06df | |||
| e8c6ac1c65 | |||
| 478f0ff20b | |||
| 8274dd46cd | |||
| d7536ea5d0 | |||
| 398b147c76 |
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
#
|
||||||
|
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
|
||||||
|
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
|
||||||
|
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
|
||||||
|
#
|
||||||
|
# Notfall-Nachricht der MarianumMobile-App
|
||||||
|
#
|
||||||
|
# Diese Datei wird bei jedem App-Start geladen.
|
||||||
|
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
|
||||||
|
#
|
||||||
|
# Steuerfelder:
|
||||||
|
# active: true schaltet die Anzeige ein (Default: false)
|
||||||
|
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
|
||||||
|
# title: optionale Überschrift
|
||||||
|
#
|
||||||
|
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
|
||||||
|
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
|
||||||
|
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
|
||||||
|
# Beispielkonfiguration:
|
||||||
|
#
|
||||||
|
# ---
|
||||||
|
# # Ein Kommentar
|
||||||
|
# active: true
|
||||||
|
# dismissible: false
|
||||||
|
# title: Wichtiger Hinweis
|
||||||
|
# ---
|
||||||
|
# Hinweistext in Markdown
|
||||||
|
#
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
active: false
|
||||||
|
dismissible: true
|
||||||
|
title: Hinweis
|
||||||
|
---
|
||||||
|
# Serverstörung
|
||||||
|
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
|
||||||
|
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
|
||||||
|
|
||||||
|
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -31,7 +31,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
||||||
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
||||||
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
||||||
private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||||
|
|
||||||
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
|
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
|
||||||
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
||||||
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
/// kSecClass = kSecClassGenericPassword
|
/// kSecClass = kSecClassGenericPassword
|
||||||
/// kSecAttrAccount = the Dart key, verbatim
|
/// kSecAttrAccount = the Dart key, verbatim
|
||||||
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
||||||
/// kSecAttrAccessGroup = the App Group id
|
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
|
||||||
/// value = raw UTF-8 bytes of the string
|
/// value = raw UTF-8 bytes of the string
|
||||||
private func keychainString(_ account: String) -> String? {
|
private func keychainString(_ account: String) -> String? {
|
||||||
let query: [CFString: Any] = [
|
let query: [CFString: Any] = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+36
-10
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
|
|||||||
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
||||||
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
||||||
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
||||||
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
|
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
|
||||||
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
|
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
|
||||||
|
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
|
||||||
|
|
||||||
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
||||||
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
||||||
@@ -67,6 +68,15 @@ iOS zeigt die fertige Notification
|
|||||||
|
|
||||||
## 3. Xcode-Checkliste (auf dem Mac)
|
## 3. Xcode-Checkliste (auf dem Mac)
|
||||||
|
|
||||||
|
> **Stand 2026-07-07:** Abschnitte 3.1–3.2 (Target anlegen, Dateien zuordnen) sind
|
||||||
|
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
|
||||||
|
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
|
||||||
|
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
|
||||||
|
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.3–3.4, 3.6)** —
|
||||||
|
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
|
||||||
|
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
|
||||||
|
> aufgesetzt werden muss).
|
||||||
|
|
||||||
### 3.1 Target anlegen
|
### 3.1 Target anlegen
|
||||||
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
||||||
2. **File → New → Target… → iOS → Notification Service Extension**.
|
2. **File → New → Target… → iOS → Notification Service Extension**.
|
||||||
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
|
|||||||
## 4. Ermittelte Keychain-Details (verbindlich)
|
## 4. Ermittelte Keychain-Details (verbindlich)
|
||||||
|
|
||||||
Die Dart-Seite schreibt mit
|
Die Dart-Seite schreibt mit
|
||||||
`IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`.
|
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
|
||||||
|
|
||||||
|
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
|
||||||
|
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
|
||||||
|
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
|
||||||
|
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
|
||||||
|
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
|
||||||
|
> Automatic Signing mit „doesn't match the entitlements file's value for the
|
||||||
|
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
|
||||||
|
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
|
||||||
|
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
|
||||||
|
|
||||||
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||||
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
||||||
|
|
||||||
@@ -141,7 +162,7 @@ Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
|||||||
| `kSecClass` | `kSecClassGenericPassword` |
|
| `kSecClass` | `kSecClassGenericPassword` |
|
||||||
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
||||||
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
||||||
| `kSecAttrAccessGroup` | `group.eu.mhsl.marianum.mobile.client.widget` |
|
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
|
||||||
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
||||||
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
||||||
|
|
||||||
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
|
|||||||
innerhalb des NSE-Budgets). Nicht implementiert.
|
innerhalb des NSE-Budgets). Nicht implementiert.
|
||||||
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
||||||
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
||||||
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
|
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
|
||||||
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
|
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
|
||||||
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
|
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
|
||||||
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
|
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
|
||||||
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
|
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
|
||||||
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
|
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
|
||||||
|
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
|
||||||
|
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
|
||||||
|
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
|
||||||
|
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
|
||||||
|
vollqualifizierten Group-String verwenden.
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ PODS:
|
|||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_app_badge (2.0.0):
|
- flutter_app_badge (2.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- home_widget (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- open_filex (0.0.2):
|
- open_filex (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- PhoneNumberKit (3.7.11):
|
- PhoneNumberKit (3.7.11):
|
||||||
@@ -14,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
|
||||||
- receive_sharing_intent (1.8.1):
|
|
||||||
- Flutter
|
|
||||||
- workmanager_apple (0.0.1):
|
- workmanager_apple (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
@@ -23,10 +19,8 @@ DEPENDENCIES:
|
|||||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||||
- home_widget (from `.symlinks/plugins/home_widget/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)
|
||||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
|
||||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -40,12 +34,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_app_badge:
|
flutter_app_badge:
|
||||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||||
home_widget:
|
|
||||||
:path: ".symlinks/plugins/home_widget/ios"
|
|
||||||
open_filex:
|
open_filex:
|
||||||
:path: ".symlinks/plugins/open_filex/ios"
|
:path: ".symlinks/plugins/open_filex/ios"
|
||||||
receive_sharing_intent:
|
|
||||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
|
||||||
workmanager_apple:
|
workmanager_apple:
|
||||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||||
|
|
||||||
@@ -53,10 +43,8 @@ SPEC CHECKSUMS:
|
|||||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
|
||||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||||
|
|
||||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||||
|
|||||||
@@ -12,14 +12,18 @@
|
|||||||
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 */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
|
||||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
||||||
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -37,6 +41,13 @@
|
|||||||
remoteGlobalIDString = AA0101010000000011111111;
|
remoteGlobalIDString = AA0101010000000011111111;
|
||||||
remoteInfo = TimetableWidgetExtension;
|
remoteInfo = TimetableWidgetExtension;
|
||||||
};
|
};
|
||||||
|
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
|
||||||
|
remoteInfo = NotificationServiceExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -48,6 +59,7 @@
|
|||||||
files = (
|
files = (
|
||||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
||||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -65,17 +77,24 @@
|
|||||||
/* 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>"; };
|
||||||
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>"; };
|
||||||
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||||
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -94,10 +113,12 @@
|
|||||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
|
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
|
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
|
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
|
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
|
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
|
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -169,6 +190,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -185,15 +214,36 @@
|
|||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
|
||||||
|
509DCCD474353408FE5806C5 /* PEM.swift */,
|
||||||
|
12B96C78930441C73F123636 /* Info.plist */,
|
||||||
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||||
|
);
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
path = NotificationServiceExtension;
|
||||||
|
sourceTree = SOURCE_ROOT;
|
||||||
|
};
|
||||||
731388A08E3B330B216381D0 /* Frameworks */ = {
|
731388A08E3B330B216381D0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
||||||
|
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -208,6 +258,9 @@
|
|||||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
||||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
||||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
||||||
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
|
||||||
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
|
||||||
|
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Flutter;
|
name = Flutter;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -222,6 +275,7 @@
|
|||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
345F4BD4143471FDA71626DE /* Pods */,
|
345F4BD4143471FDA71626DE /* Pods */,
|
||||||
731388A08E3B330B216381D0 /* Frameworks */,
|
731388A08E3B330B216381D0 /* Frameworks */,
|
||||||
|
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -231,6 +285,7 @@
|
|||||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
||||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
||||||
|
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -278,9 +333,6 @@
|
|||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
packageProductDependencies = (
|
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
);
|
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
@@ -299,8 +351,12 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
||||||
AA0101090000000011111111 /* PBXTargetDependency */,
|
AA0101090000000011111111 /* PBXTargetDependency */,
|
||||||
|
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -325,13 +381,27 @@
|
|||||||
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
|
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
DF8C1170E7DF96711030EAC0 /* Sources */,
|
||||||
|
D2447D92E9CCD96B3292B17B /* Frameworks */,
|
||||||
|
239068341DC6E6B36193EC96 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
productName = NotificationServiceExtension;
|
||||||
|
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
packageReferences = (
|
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
|
||||||
);
|
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
@@ -360,6 +430,9 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -367,11 +440,19 @@
|
|||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
3321F8042FB1C00C0011C712 /* Share Extension */,
|
3321F8042FB1C00C0011C712 /* Share Extension */,
|
||||||
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
||||||
|
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
239068341DC6E6B36193EC96 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
3321F8032FB1C00C0011C712 /* Resources */ = {
|
3321F8032FB1C00C0011C712 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -520,6 +601,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
DF8C1170E7DF96711030EAC0 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
|
||||||
|
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -528,6 +618,12 @@
|
|||||||
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
||||||
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
|
||||||
|
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
||||||
@@ -929,6 +1025,29 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
A2B046F78AD55232F08583F3 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||||
|
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
AA01010A0000000011111111 /* Debug */ = {
|
AA01010A0000000011111111 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
||||||
@@ -1051,6 +1170,54 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
|
E5EE58583E83B7694F6C3C5E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||||
|
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
F418DF43742DC2E30B161652 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||||
|
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -1094,13 +1261,25 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E5EE58583E83B7694F6C3C5E /* Release */,
|
||||||
|
A2B046F78AD55232F08583F3 /* Debug */,
|
||||||
|
F418DF43742DC2E30B161652 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/app-check.git",
|
"location" : "https://github.com/google/app-check.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
||||||
"version" : "11.2.0"
|
"version" : "11.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
|
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||||
"version" : "12.13.0"
|
"version" : "12.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
|
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
||||||
"version" : "3.5.0"
|
"version" : "3.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
|
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||||
"version" : "12.13.0"
|
"version" : "12.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||||
"version" : "8.1.0"
|
"version" : "8.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/leveldb.git",
|
"location" : "https://github.com/firebase/leveldb.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||||
"version" : "1.22.2"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,8 +131,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/nanopb.git",
|
"location" : "https://github.com/firebase/nanopb.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||||
"version" : "2.30910.0"
|
"version" : "2.30910.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/promises.git",
|
"location" : "https://github.com/google/promises.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
"version" : "2.4.0"
|
"version" : "2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import UserNotifications
|
|||||||
private let markReadActionId = "TALK_MARK_READ"
|
private let markReadActionId = "TALK_MARK_READ"
|
||||||
|
|
||||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
||||||
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||||
private let usernameAccount = "nextcloud_username"
|
private let usernameAccount = "nextcloud_username"
|
||||||
private let appPasswordAccount = "nextcloud_app_password"
|
private let appPasswordAccount = "nextcloud_app_password"
|
||||||
private let baseUrlAccount = "nextcloud_base_url"
|
private let baseUrlAccount = "nextcloud_base_url"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
) {
|
) {
|
||||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
for context in connectionOptions.urlContexts {
|
for context in connectionOptions.urlContexts {
|
||||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||||
UIApplication.shared,
|
UIApplication.shared,
|
||||||
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
||||||
)
|
)
|
||||||
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
|
|
||||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
for context in URLContexts {
|
for context in URLContexts {
|
||||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||||
UIApplication.shared,
|
UIApplication.shared,
|
||||||
open: context.url,
|
open: context.url,
|
||||||
options: [:]
|
options: [:]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
// Datenmodell muss byte-für-byte zu dem passen, was
|
// Datenmodell muss byte-für-byte zu dem passen, was
|
||||||
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||||
private enum SharedMediaType: String, Codable {
|
private enum SharedMediaType: String, Codable {
|
||||||
case image, video, text, file, url
|
case image, video, text, file, url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
class ApiRequest {}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||||
|
|
||||||
|
/// Demo fixtures for the absence-report form: the class dropdown and the
|
||||||
|
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
|
||||||
|
/// silently drift the demo shape away from what the queries parse.
|
||||||
|
class DemoAbsence {
|
||||||
|
const DemoAbsence._();
|
||||||
|
|
||||||
|
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
|
||||||
|
|
||||||
|
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
className: '10a',
|
||||||
|
phone: '0123 456789',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'data/demo_absence.dart';
|
||||||
import 'data/demo_breaker.dart';
|
import 'data/demo_breaker.dart';
|
||||||
import 'data/demo_holidays.dart';
|
import 'data/demo_holidays.dart';
|
||||||
import 'data/demo_timetable.dart';
|
import 'data/demo_timetable.dart';
|
||||||
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
|
|||||||
.toList();
|
.toList();
|
||||||
case 'breaker':
|
case 'breaker':
|
||||||
return DemoBreaker.none().toJson();
|
return DemoBreaker.none().toJson();
|
||||||
|
case 'absence/classes':
|
||||||
|
return DemoAbsence.classes();
|
||||||
|
case 'absence/prefill':
|
||||||
|
return DemoAbsence.prefill().toJson();
|
||||||
case 'timetable/elements/teachers':
|
case 'timetable/elements/teachers':
|
||||||
case 'timetable/elements/students':
|
case 'timetable/elements/students':
|
||||||
case 'timetable/elements/classes':
|
case 'timetable/elements/classes':
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/// A backend-independent emergency notice loaded from a foreign server URL —
|
||||||
|
/// frontmatter (control fields) plus a free Markdown body, so it stays
|
||||||
|
/// hand-writable in an outage. See [parse] for the format.
|
||||||
|
class EmergencyNotice {
|
||||||
|
/// `false` renders full-screen and blocks back/barrier taps.
|
||||||
|
final bool dismissible;
|
||||||
|
final String? title;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
const EmergencyNotice({
|
||||||
|
required this.dismissible,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Parses the raw file, or returns `null` when there is nothing to show.
|
||||||
|
/// Never throws — malformed input yields `null` so a broken file can't break
|
||||||
|
/// the app.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// ---
|
||||||
|
/// active: true # required truthy, else null; # lines are comments
|
||||||
|
/// dismissible: true # default true
|
||||||
|
/// title: Störung # optional
|
||||||
|
/// ---
|
||||||
|
/// Free **markdown** body (everything after the closing ---).
|
||||||
|
/// ```
|
||||||
|
static EmergencyNotice? parse(String raw) {
|
||||||
|
final lines = raw
|
||||||
|
.replaceAll('\r\n', '\n')
|
||||||
|
.replaceAll('\r', '\n')
|
||||||
|
.split('\n');
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.length && lines[i].trim().isEmpty) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= lines.length || lines[i].trim() != '---') return null;
|
||||||
|
final openIndex = i;
|
||||||
|
|
||||||
|
var closeIndex = -1;
|
||||||
|
for (var j = openIndex + 1; j < lines.length; j++) {
|
||||||
|
if (lines[j].trim() == '---') {
|
||||||
|
closeIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closeIndex == -1) return null;
|
||||||
|
|
||||||
|
final meta = <String, String>{};
|
||||||
|
for (var j = openIndex + 1; j < closeIndex; j++) {
|
||||||
|
final line = lines[j].trim();
|
||||||
|
if (line.isEmpty || line.startsWith('#')) continue;
|
||||||
|
final sep = line.indexOf(':');
|
||||||
|
if (sep <= 0) continue;
|
||||||
|
final key = line.substring(0, sep).trim().toLowerCase();
|
||||||
|
final value = line.substring(sep + 1).trim();
|
||||||
|
meta[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_parseBool(meta['active']) != true) return null;
|
||||||
|
|
||||||
|
final body = lines.sublist(closeIndex + 1).join('\n').trim();
|
||||||
|
if (body.isEmpty) return null;
|
||||||
|
|
||||||
|
final title = meta['title'];
|
||||||
|
return EmergencyNotice(
|
||||||
|
dismissible: _parseBool(meta['dismissible']) ?? true,
|
||||||
|
title: (title == null || title.isEmpty) ? null : title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool? _parseBool(String? value) {
|
||||||
|
switch (value?.trim().toLowerCase()) {
|
||||||
|
case 'true':
|
||||||
|
case 'yes':
|
||||||
|
case '1':
|
||||||
|
case 'on':
|
||||||
|
return true;
|
||||||
|
case 'false':
|
||||||
|
case 'no':
|
||||||
|
case '0':
|
||||||
|
case 'off':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'emergency_notice.dart';
|
||||||
|
|
||||||
|
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
|
||||||
|
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
|
||||||
|
/// Never throws: any failure yields `null` (nothing shown).
|
||||||
|
///
|
||||||
|
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
|
||||||
|
/// it lives only for the process, so a cold start always fetches fresh.
|
||||||
|
class EmergencyNoticeClient {
|
||||||
|
EmergencyNoticeClient();
|
||||||
|
|
||||||
|
static const Duration cacheTtl = Duration(minutes: 1);
|
||||||
|
|
||||||
|
EmergencyNotice? _cached;
|
||||||
|
String? _cachedUrl;
|
||||||
|
DateTime? _cachedAt;
|
||||||
|
|
||||||
|
Future<EmergencyNotice?> fetch(String url) async {
|
||||||
|
final cachedAt = _cachedAt;
|
||||||
|
if (cachedAt != null &&
|
||||||
|
_cachedUrl == url &&
|
||||||
|
DateTime.now().difference(cachedAt) < cacheTtl) {
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmergencyNotice? result;
|
||||||
|
try {
|
||||||
|
final dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
receiveTimeout: const Duration(seconds: 5),
|
||||||
|
sendTimeout: const Duration(seconds: 5),
|
||||||
|
responseType: ResponseType.plain,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final response = await dio.get<String>(url);
|
||||||
|
final raw = response.data;
|
||||||
|
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache failures too, so a down server isn't retried on every resume.
|
||||||
|
_cached = result;
|
||||||
|
_cachedUrl = url;
|
||||||
|
_cachedAt = DateTime.now();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../api_error.dart';
|
import '../api_error.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 'auth_exception.dart';
|
||||||
@@ -59,9 +60,8 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final preview = previewBody(error.body);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||||
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
return AuthException.unauthorized(technicalDetails: detail);
|
return AuthException.unauthorized(technicalDetails: detail);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'errors/auth_exception.dart';
|
||||||
|
import 'errors/network_exception.dart';
|
||||||
|
import 'errors/not_found_exception.dart';
|
||||||
|
import 'errors/server_exception.dart';
|
||||||
|
|
||||||
|
/// Runs [send] and converts transport-level failures (socket/timeout/client
|
||||||
|
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
|
||||||
|
/// Passes through whatever [send] produces, including `null` for the base-class
|
||||||
|
/// request hooks that may skip the call.
|
||||||
|
Future<http.Response?> sendGuarded(
|
||||||
|
String label,
|
||||||
|
Future<http.Response>? Function() send,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await send();
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||||
|
} on TimeoutException catch (e) {
|
||||||
|
throw NetworkException.timeout(technicalDetails: '$label: $e');
|
||||||
|
} on http.ClientException catch (e) {
|
||||||
|
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
|
||||||
|
/// embedded in an [AppException]'s technical details without dumping headers.
|
||||||
|
String previewBody(String body) {
|
||||||
|
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}…' : collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
|
||||||
|
String httpErrorDetail(String label, String body, int status) {
|
||||||
|
final preview = previewBody(body);
|
||||||
|
return preview.isEmpty
|
||||||
|
? '$label -> HTTP $status'
|
||||||
|
: '$label -> HTTP $status body=$preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
|
||||||
|
/// [detail] as technical details: 401/403 map to auth errors, 404 to
|
||||||
|
/// not-found, everything else to a generic server error.
|
||||||
|
Never throwForStatus(int status, String detail) {
|
||||||
|
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||||
|
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||||
|
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||||
|
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@@ -38,9 +37,6 @@ class AutocompleteApi {
|
|||||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
||||||
return AutocompleteResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../model/account_data.dart';
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
|
||||||
import '../../errors/network_exception.dart';
|
|
||||||
import '../../errors/not_found_exception.dart';
|
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Mix of two Nextcloud surfaces:
|
/// Mix of two Nextcloud surfaces:
|
||||||
@@ -42,30 +37,17 @@ Future<http.Response> _send(
|
|||||||
) async {
|
) async {
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
final http.Response response;
|
final response = (await sendGuarded(
|
||||||
try {
|
'Cloud $uri',
|
||||||
response = await perform(uri, headers);
|
() => perform(uri, headers),
|
||||||
} on SocketException catch (e) {
|
))!;
|
||||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final status = response.statusCode;
|
final status = response.statusCode;
|
||||||
if (status >= 200 && status < 300) return response;
|
if (status >= 200 && status < 300) return response;
|
||||||
|
|
||||||
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final detail = httpErrorDetail('Cloud $uri', response.body, status);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
|
||||||
final detail = body.isEmpty
|
|
||||||
? 'Cloud $uri -> HTTP $status'
|
|
||||||
: 'Cloud $uri -> HTTP $status body=$preview';
|
|
||||||
log(detail);
|
log(detail);
|
||||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
throwForStatus(status, detail);
|
||||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
|
||||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
|
||||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SetUserAvatar {
|
class SetUserAvatar {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
import '../../model/account_data.dart';
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
|
||||||
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
|
|||||||
class NextcloudOcs {
|
class NextcloudOcs {
|
||||||
NextcloudOcs._();
|
NextcloudOcs._();
|
||||||
|
|
||||||
|
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
|
||||||
|
/// every response nests its `meta`/`data` under).
|
||||||
|
static Map<String, dynamic> decode(String raw) =>
|
||||||
|
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
||||||
|
|
||||||
static Map<String, String> headers() => {
|
static Map<String, String> headers() => {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@@ -28,9 +27,7 @@ class SearchFiles {
|
|||||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
|
||||||
final data = ocs['data'] as Map<String, dynamic>;
|
|
||||||
return SearchFilesResponse.fromJson(data);
|
return SearchFilesResponse.fromJson(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_chat_params.dart';
|
import 'get_chat_params.dart';
|
||||||
import 'get_chat_response.dart';
|
import 'get_chat_response.dart';
|
||||||
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
|
|||||||
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetChatResponse assemble(String raw) {
|
GetChatResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> request(
|
Future<Response> request(
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
|
|||||||
GetChatParams(
|
GetChatParams(
|
||||||
lookIntoFuture: GetChatParamsSwitch.off,
|
lookIntoFuture: GetChatParamsSwitch.off,
|
||||||
setReadMarker: GetChatParamsSwitch.on,
|
setReadMarker: GetChatParamsSwitch.on,
|
||||||
limit: 200,
|
// Small initial page; also the per-chat offline snapshot written to
|
||||||
|
// localstore. Older messages are paged in on scroll-up via
|
||||||
|
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
||||||
|
limit: 50,
|
||||||
),
|
),
|
||||||
).run(),
|
).run(),
|
||||||
fromJson: GetChatResponse.fromJson,
|
fromJson: GetChatResponse.fromJson,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
||||||
|
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
|
||||||
|
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
|
||||||
|
/// when there are no older messages left — a normal "start of chat" outcome here.
|
||||||
|
/// `setReadMarker=off` so paging into history never moves the read cursor.
|
||||||
|
class GetChatHistory {
|
||||||
|
final String chatToken;
|
||||||
|
final int lastKnownMessageId;
|
||||||
|
final int limit;
|
||||||
|
|
||||||
|
GetChatHistory({
|
||||||
|
required this.chatToken,
|
||||||
|
required this.lastKnownMessageId,
|
||||||
|
required this.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
||||||
|
Future<GetChatResponse?> run() async {
|
||||||
|
final params = GetChatParams(
|
||||||
|
lookIntoFuture: GetChatParamsSwitch.off,
|
||||||
|
lastKnownMessageId: lastKnownMessageId,
|
||||||
|
includeLastKnown: GetChatParamsSwitch.off,
|
||||||
|
setReadMarker: GetChatParamsSwitch.off,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
final uri = NextcloudOcs.uri(
|
||||||
|
'apps/spreed/api/v1/chat/$chatToken',
|
||||||
|
queryParameters: params.toJson(),
|
||||||
|
);
|
||||||
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
|
final response = (await sendGuarded(
|
||||||
|
'GetChatHistory $uri',
|
||||||
|
() => http.get(uri, headers: headers),
|
||||||
|
))!;
|
||||||
|
|
||||||
|
final status = response.statusCode;
|
||||||
|
if (status == 304) return null;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||||
|
..headers = response.headers;
|
||||||
|
}
|
||||||
|
throw ServerException(
|
||||||
|
statusCode: status,
|
||||||
|
technicalDetails: 'GetChatHistory $uri: HTTP $status',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../errors/network_exception.dart';
|
|
||||||
import '../../../errors/server_exception.dart';
|
import '../../../errors/server_exception.dart';
|
||||||
|
import '../../../http_errors.dart';
|
||||||
import '../../nextcloud_ocs.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';
|
||||||
@@ -41,24 +37,17 @@ class LongPollChat {
|
|||||||
);
|
);
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
final http.Response response;
|
final response = (await sendGuarded(
|
||||||
try {
|
'LongPollChat $uri',
|
||||||
response = await http
|
() => http
|
||||||
.get(uri, headers: headers)
|
.get(uri, headers: headers)
|
||||||
.timeout(Duration(seconds: timeoutSeconds + 15));
|
.timeout(Duration(seconds: timeoutSeconds + 15)),
|
||||||
} on TimeoutException catch (e) {
|
))!;
|
||||||
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final status = response.statusCode;
|
final status = response.statusCode;
|
||||||
if (status == 304) return null;
|
if (status == 304) return null;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
|
|
||||||
..headers = response.headers;
|
..headers = response.headers;
|
||||||
}
|
}
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../get_poll/get_poll_state_response.dart';
|
import '../get_poll/get_poll_state_response.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
|
|
||||||
@@ -12,12 +11,8 @@ class ClosePoll extends TalkApi<GetPollStateResponse> {
|
|||||||
: super('v1/poll/$token/$pollId', null);
|
: super('v1/poll/$token/$pollId', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetPollStateResponse assemble(String raw) {
|
GetPollStateResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetPollStateResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'create_room_params.dart';
|
import 'create_room_params.dart';
|
||||||
import 'create_room_response.dart';
|
import 'create_room_response.dart';
|
||||||
@@ -13,10 +12,8 @@ class CreateRoom extends TalkApi<CreateRoomResponse> {
|
|||||||
CreateRoom(this.params) : super('v4/room', params);
|
CreateRoom(this.params) : super('v4/room', params);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CreateRoomResponse assemble(String raw) {
|
CreateRoomResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response>? request(
|
Future<Response>? request(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_participants_response.dart';
|
import 'get_participants_response.dart';
|
||||||
|
|
||||||
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
|
|||||||
GetParticipants(this.token) : super('v4/room/$token/participants', null);
|
GetParticipants(this.token) : super('v4/room/$token/participants', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetParticipantsResponse assemble(String raw) {
|
GetParticipantsResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetParticipantsResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_poll_state_response.dart';
|
import 'get_poll_state_response.dart';
|
||||||
|
|
||||||
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
|
|||||||
: super('v1/poll/$token/$pollId', null);
|
: super('v1/poll/$token/$pollId', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetPollStateResponse assemble(String raw) {
|
GetPollStateResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetPollStateResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_reactions_response.dart';
|
import 'get_reactions_response.dart';
|
||||||
|
|
||||||
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
|
|||||||
: super('v1/reaction/$chatToken/$messageId', null);
|
: super('v1/reaction/$chatToken/$messageId', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetReactionsResponse assemble(String raw) {
|
GetReactionsResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetReactionsResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response>? request(
|
Future<Response>? request(
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
import 'get_shared_items_response.dart';
|
||||||
|
|
||||||
|
/// Fetches the messages that shared an item of a given [objectType] in a chat
|
||||||
|
/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using
|
||||||
|
/// the `X-Chat-Last-Given` response header (see
|
||||||
|
/// [GetSharedItemsResponse.lastGivenMessageId]).
|
||||||
|
///
|
||||||
|
/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`,
|
||||||
|
/// `deckcard`, `recording`, `other`.
|
||||||
|
class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
|
||||||
|
GetSharedItems(
|
||||||
|
String token, {
|
||||||
|
required String objectType,
|
||||||
|
int limit = 20,
|
||||||
|
int? lastKnownMessageId,
|
||||||
|
}) : super(
|
||||||
|
'v1/chat/$token/share',
|
||||||
|
null,
|
||||||
|
getParameters: {
|
||||||
|
'objectType': objectType,
|
||||||
|
'limit': limit,
|
||||||
|
'lastKnownMessageId': ?lastKnownMessageId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetSharedItemsResponse assemble(String raw) =>
|
||||||
|
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.get(uri, headers: headers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
import 'get_shared_items_overview_response.dart';
|
||||||
|
|
||||||
|
/// Fetches the latest shared items of every type at once (Talk's
|
||||||
|
/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to
|
||||||
|
/// show and to seed their first page. [limit] caps the items returned per type.
|
||||||
|
class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
|
||||||
|
GetSharedItemsOverview(String token, {int limit = 20})
|
||||||
|
: super(
|
||||||
|
'v1/chat/$token/share/overview',
|
||||||
|
null,
|
||||||
|
getParameters: {'limit': limit},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetSharedItemsOverviewResponse assemble(String raw) =>
|
||||||
|
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.get(uri, headers: headers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import '../../../api_response.dart';
|
||||||
|
import '../chat/get_chat_response.dart';
|
||||||
|
|
||||||
|
/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared
|
||||||
|
/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`,
|
||||||
|
/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the
|
||||||
|
/// message structure.
|
||||||
|
class GetSharedItemsOverviewResponse extends ApiResponse {
|
||||||
|
final Map<String, List<GetChatResponseObject>> itemsByType;
|
||||||
|
|
||||||
|
GetSharedItemsOverviewResponse(this.itemsByType);
|
||||||
|
|
||||||
|
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||||
|
final data = ocs['data'];
|
||||||
|
final result = <String, List<GetChatResponseObject>>{};
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
for (final entry in data.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
final raw = switch (value) {
|
||||||
|
List<dynamic> list => list,
|
||||||
|
Map<dynamic, dynamic> map => map.values,
|
||||||
|
_ => const <dynamic>[],
|
||||||
|
};
|
||||||
|
result[entry.key] = raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(GetChatResponseObject.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GetSharedItemsOverviewResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import '../../../api_response.dart';
|
||||||
|
import '../chat/get_chat_response.dart';
|
||||||
|
|
||||||
|
/// Response of Talk's shared-items endpoints. The message objects carry the
|
||||||
|
/// same structure as regular chat messages, so we reuse [GetChatResponseObject]
|
||||||
|
/// (the shared file lives in `messageParameters['file']`).
|
||||||
|
///
|
||||||
|
/// The server returns `data` either as a list or as a message-id-keyed map
|
||||||
|
/// depending on version; [fromOcs] normalises both to a list.
|
||||||
|
class GetSharedItemsResponse extends ApiResponse {
|
||||||
|
final List<GetChatResponseObject> items;
|
||||||
|
|
||||||
|
GetSharedItemsResponse(this.items);
|
||||||
|
|
||||||
|
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||||
|
final data = ocs['data'];
|
||||||
|
final raw = switch (data) {
|
||||||
|
List<dynamic> list => list,
|
||||||
|
Map<dynamic, dynamic> map => map.values,
|
||||||
|
_ => const <dynamic>[],
|
||||||
|
};
|
||||||
|
final items = raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(GetChatResponseObject.fromJson)
|
||||||
|
.toList();
|
||||||
|
return GetSharedItemsResponse(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset for the next page, taken from the `X-Chat-Last-Given` header.
|
||||||
|
/// Null when the header is absent (older server) — treat that as "stop".
|
||||||
|
int? get lastGivenMessageId {
|
||||||
|
final value = headers?['x-chat-last-given'];
|
||||||
|
return value == null ? null : int.tryParse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_room_params.dart';
|
import 'get_room_params.dart';
|
||||||
import 'get_room_response.dart';
|
import 'get_room_response.dart';
|
||||||
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
|||||||
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetRoomResponse assemble(String raw) {
|
GetRoomResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../api_params.dart';
|
import '../../api_params.dart';
|
||||||
import '../../api_request.dart';
|
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
|
||||||
import '../../errors/network_exception.dart';
|
import '../../errors/network_exception.dart';
|
||||||
import '../../errors/not_found_exception.dart';
|
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
enum TalkApiMethod { get, post, put, delete }
|
abstract class TalkApi<T extends ApiResponse?> {
|
||||||
|
|
||||||
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
|
||||||
String path;
|
String path;
|
||||||
ApiParams? body;
|
ApiParams? body;
|
||||||
Map<String, String>? headers;
|
Map<String, String>? headers;
|
||||||
Map<String, dynamic>? getParameters;
|
Map<String, dynamic>? getParameters;
|
||||||
|
|
||||||
http.Response? response;
|
|
||||||
|
|
||||||
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
||||||
|
|
||||||
Future<http.Response>? request(
|
Future<http.Response>? request(
|
||||||
@@ -40,22 +31,15 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
|||||||
);
|
);
|
||||||
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
||||||
|
|
||||||
final http.Response data;
|
final data = await sendGuarded(
|
||||||
try {
|
'Talk $endpoint',
|
||||||
final raw = await request(endpoint, body, mergedHeaders);
|
() => request(endpoint, body, mergedHeaders),
|
||||||
if (raw == null) {
|
);
|
||||||
throw const NetworkException(
|
if (data == null) {
|
||||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
throw const NetworkException(
|
||||||
technicalDetails: 'Talk request returned null',
|
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||||
);
|
technicalDetails: 'Talk request returned null',
|
||||||
}
|
);
|
||||||
data = raw;
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final status = data.statusCode;
|
final status = data.statusCode;
|
||||||
@@ -63,20 +47,9 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
|||||||
// Talk's OCS errors carry the real reason in the body (expired session,
|
// Talk's OCS errors carry the real reason in the body (expired session,
|
||||||
// removed participant, ...); include a trimmed preview so the dialog and
|
// removed participant, ...); include a trimmed preview so the dialog and
|
||||||
// logs surface the cause instead of just the bare status code.
|
// logs surface the cause instead of just the bare status code.
|
||||||
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
|
||||||
final detail = body.isEmpty
|
|
||||||
? 'Talk $endpoint -> HTTP $status'
|
|
||||||
: 'Talk $endpoint -> HTTP $status body=$preview';
|
|
||||||
log(detail);
|
log(detail);
|
||||||
if (status == 401) {
|
throwForStatus(status, detail);
|
||||||
throw AuthException.unauthorized(technicalDetails: detail);
|
|
||||||
}
|
|
||||||
if (status == 403) {
|
|
||||||
throw AuthException.forbidden(technicalDetails: detail);
|
|
||||||
}
|
|
||||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
|
||||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../get_poll/get_poll_state_response.dart';
|
import '../get_poll/get_poll_state_response.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'vote_poll_params.dart';
|
import 'vote_poll_params.dart';
|
||||||
@@ -22,12 +23,8 @@ class VotePoll extends TalkApi<GetPollStateResponse> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetPollStateResponse assemble(String raw) {
|
GetPollStateResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetPollStateResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response>? request(
|
Future<http.Response>? request(
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import 'package:nextcloud/nextcloud.dart';
|
|||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../model/account_data.dart';
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../api_request.dart';
|
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
|
|
||||||
abstract class WebdavApi<T> extends ApiRequest {
|
abstract class WebdavApi<T> {
|
||||||
T genericParams;
|
T genericParams;
|
||||||
|
|
||||||
WebdavApi(this.genericParams) {
|
WebdavApi(this.genericParams) {
|
||||||
|
|||||||
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
|
|||||||
class MarianumConnectApi {
|
class MarianumConnectApi {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 20);
|
static const Duration _receiveTimeout = Duration(seconds: 20);
|
||||||
|
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
static final Dio _instance = _build();
|
static final Dio _instance = _build();
|
||||||
|
|
||||||
static Dio dio() => _instance;
|
static Dio dio() => _instance;
|
||||||
|
|
||||||
|
/// A fresh dio with the standard JSON options but no interceptors — used by
|
||||||
|
/// the auth queries (login/verify) that must bypass the bearer/demo
|
||||||
|
/// interceptors to avoid a re-auth loop.
|
||||||
|
static Dio plainDio() => Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: _connectTimeout,
|
||||||
|
sendTimeout: _connectTimeout,
|
||||||
|
receiveTimeout: _plainReceiveTimeout,
|
||||||
|
responseType: ResponseType.json,
|
||||||
|
contentType: 'application/json',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
static Dio _build() {
|
static Dio _build() {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
|
|||||||
@@ -26,4 +26,36 @@ abstract class MarianumConnectQuery {
|
|||||||
throw mapMarianumConnectError(e);
|
throw mapMarianumConnectError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||||
|
Future<T> getObject<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic> json) fromJson, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
|
endpoint(path),
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
|
return fromJson(response.data!);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// GETs [path] and maps each element of the JSON array body with [fromJson].
|
||||||
|
Future<List<T>> getList<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic> json) fromJson, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
|
endpoint(path),
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
|
return response.data!
|
||||||
|
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||||
|
String isoDate(DateTime d) =>
|
||||||
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
|
/// GETs the selectable classes for the absence form (`absence/classes`). The
|
||||||
|
/// body is a bare JSON string array, so [getList] (which maps objects) does not
|
||||||
|
/// fit — read the raw list and cast.
|
||||||
|
class AbsenceClasses extends MarianumConnectQuery {
|
||||||
|
AbsenceClasses({super.dio});
|
||||||
|
|
||||||
|
Future<List<String>> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(endpoint('absence/classes'));
|
||||||
|
return response.data!.cast<String>();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import 'absence_prefill_response.dart';
|
||||||
|
|
||||||
|
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
|
||||||
|
class AbsencePrefill extends MarianumConnectQuery {
|
||||||
|
AbsencePrefill({super.dio});
|
||||||
|
|
||||||
|
Future<AbsencePrefillResponse> run() =>
|
||||||
|
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'absence_prefill_response.g.dart';
|
||||||
|
|
||||||
|
/// Prefill for the absence-report form: identity from LDAP plus the phone
|
||||||
|
/// number from the user's last report (empty strings when unknown).
|
||||||
|
@JsonSerializable()
|
||||||
|
class AbsencePrefillResponse {
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String firstName;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String lastName;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String className;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String phone;
|
||||||
|
|
||||||
|
AbsencePrefillResponse({
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.className,
|
||||||
|
required this.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AbsencePrefillResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$AbsencePrefillResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$AbsencePrefillResponseToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'absence_prefill_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => AbsencePrefillResponse(
|
||||||
|
firstName: json['firstName'] as String? ?? '',
|
||||||
|
lastName: json['lastName'] as String? ?? '',
|
||||||
|
className: json['className'] as String? ?? '',
|
||||||
|
phone: json['phone'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$AbsencePrefillResponseToJson(
|
||||||
|
AbsencePrefillResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'firstName': instance.firstName,
|
||||||
|
'lastName': instance.lastName,
|
||||||
|
'className': instance.className,
|
||||||
|
'phone': instance.phone,
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
|
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
|
||||||
|
/// Empty identity fields are backfilled from LDAP server-side; validation
|
||||||
|
/// (all fields required, class must exist, no past start date, end >= start)
|
||||||
|
/// also runs server-side and mirrors the client checks.
|
||||||
|
class AbsenceSubmit extends MarianumConnectQuery {
|
||||||
|
AbsenceSubmit({super.dio});
|
||||||
|
|
||||||
|
Future<void> run({
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
required String className,
|
||||||
|
required DateTime absentFrom,
|
||||||
|
required DateTime absentUntil,
|
||||||
|
required String phone,
|
||||||
|
required String note,
|
||||||
|
}) => guard(() async {
|
||||||
|
await dio.post<void>(
|
||||||
|
endpoint('absence'),
|
||||||
|
data: {
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'className': className,
|
||||||
|
'absentFrom': isoDate(absentFrom),
|
||||||
|
'absentUntil': isoDate(absentUntil),
|
||||||
|
'phone': phone,
|
||||||
|
'note': note,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'auth_login_response.dart';
|
import 'auth_login_response.dart';
|
||||||
|
|
||||||
@@ -9,9 +10,6 @@ import 'auth_login_response.dart';
|
|||||||
/// run through the shared dio instance — that one has the interceptor, which
|
/// run through the shared dio instance — that one has the interceptor, which
|
||||||
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
||||||
class AuthLogin extends MarianumConnectQuery {
|
class AuthLogin extends MarianumConnectQuery {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
|
|
||||||
AuthLogin({
|
AuthLogin({
|
||||||
@@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery {
|
|||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
super(dio: dio ?? _buildDio());
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
|
|
||||||
static Dio _buildDio() => Dio(
|
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: _connectTimeout,
|
|
||||||
receiveTimeout: _receiveTimeout,
|
|
||||||
sendTimeout: _connectTimeout,
|
|
||||||
responseType: ResponseType.json,
|
|
||||||
contentType: 'application/json',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<AuthLoginResponse> run({
|
Future<AuthLoginResponse> run({
|
||||||
required String username,
|
required String username,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../../errors/auth_exception.dart';
|
import '../../../errors/auth_exception.dart';
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Probes that the stored bearer token still maps to the given credentials.
|
/// Probes that the stored bearer token still maps to the given credentials.
|
||||||
@@ -12,9 +13,6 @@ import '../../marianumconnect_query.dart';
|
|||||||
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||||
/// and obscure a real 401 with a silent re-login.
|
/// and obscure a real 401 with a silent re-login.
|
||||||
class AuthVerify extends MarianumConnectQuery {
|
class AuthVerify extends MarianumConnectQuery {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
|
|
||||||
AuthVerify({
|
AuthVerify({
|
||||||
@@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery {
|
|||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
super(dio: dio ?? _buildDio());
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
|
|
||||||
static Dio _buildDio() => Dio(
|
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: _connectTimeout,
|
|
||||||
sendTimeout: _connectTimeout,
|
|
||||||
receiveTimeout: _receiveTimeout,
|
|
||||||
responseType: ResponseType.json,
|
|
||||||
contentType: 'application/json',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
||||||
/// user, token missing, or token rejected), other [AppException]s on
|
/// user, token missing, or token rejected), other [AppException]s on
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import 'get_breakers_response.dart';
|
|||||||
class GetBreakers extends MarianumConnectQuery {
|
class GetBreakers extends MarianumConnectQuery {
|
||||||
GetBreakers({super.dio});
|
GetBreakers({super.dio});
|
||||||
|
|
||||||
Future<GetBreakersResponse> run() => guard(() async {
|
Future<GetBreakersResponse> run() =>
|
||||||
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
|
getObject('breaker', GetBreakersResponse.fromJson);
|
||||||
return GetBreakersResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ import 'get_capabilities_response.dart';
|
|||||||
class GetCapabilities extends MarianumConnectQuery {
|
class GetCapabilities extends MarianumConnectQuery {
|
||||||
GetCapabilities({super.dio});
|
GetCapabilities({super.dio});
|
||||||
|
|
||||||
Future<CapabilitiesResponse> run() => guard(() async {
|
Future<CapabilitiesResponse> run() =>
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
getObject('me/capabilities', CapabilitiesResponse.fromJson);
|
||||||
endpoint('me/capabilities'),
|
|
||||||
);
|
|
||||||
return CapabilitiesResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,5 @@ import '../../models/mc_holiday.dart';
|
|||||||
class GetHolidays extends MarianumConnectQuery {
|
class GetHolidays extends MarianumConnectQuery {
|
||||||
GetHolidays({super.dio});
|
GetHolidays({super.dio});
|
||||||
|
|
||||||
Future<List<McHoliday>> run() => guard(() async {
|
Future<List<McHoliday>> run() => getList('holidays', McHoliday.fromJson);
|
||||||
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
|
|
||||||
return response.data!
|
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import 'get_ticker_response.dart';
|
|||||||
class GetTicker extends MarianumConnectQuery {
|
class GetTicker extends MarianumConnectQuery {
|
||||||
GetTicker({super.dio});
|
GetTicker({super.dio});
|
||||||
|
|
||||||
Future<TickerResponse> run() => guard(() async {
|
Future<TickerResponse> run() =>
|
||||||
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
|
getObject('ticker', TickerResponse.fromJson);
|
||||||
return TickerResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ import 'get_ticker_nav_response.dart';
|
|||||||
class GetTickerNav extends MarianumConnectQuery {
|
class GetTickerNav extends MarianumConnectQuery {
|
||||||
GetTickerNav({super.dio});
|
GetTickerNav({super.dio});
|
||||||
|
|
||||||
Future<TickerNavResponse> run() => guard(() async {
|
Future<TickerNavResponse> run() =>
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
getObject('ticker/pages', TickerNavResponse.fromJson);
|
||||||
endpoint('ticker/pages'),
|
|
||||||
);
|
|
||||||
return TickerNavResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -4,10 +4,8 @@ import '../../marianumconnect_query.dart';
|
|||||||
class TimetableCustomEventsGet extends MarianumConnectQuery {
|
class TimetableCustomEventsGet extends MarianumConnectQuery {
|
||||||
TimetableCustomEventsGet({super.dio});
|
TimetableCustomEventsGet({super.dio});
|
||||||
|
|
||||||
Future<GetCustomTimetableEventResponse> run() => guard(() async {
|
Future<GetCustomTimetableEventResponse> run() => getObject(
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
'timetable/custom-events',
|
||||||
endpoint('timetable/custom-events'),
|
GetCustomTimetableEventResponse.fromJson,
|
||||||
);
|
);
|
||||||
return GetCustomTimetableEventResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import 'timetable_get_classes_response.dart';
|
|||||||
class TimetableGetClasses extends MarianumConnectQuery {
|
class TimetableGetClasses extends MarianumConnectQuery {
|
||||||
TimetableGetClasses({super.dio});
|
TimetableGetClasses({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetClassesResponse> run() => guard(() async {
|
Future<TimetableGetClassesResponse> run() async =>
|
||||||
final response = await dio.get<List<dynamic>>(
|
TimetableGetClassesResponse(
|
||||||
endpoint('timetable/elements/classes'),
|
result: await getList(
|
||||||
);
|
'timetable/elements/classes',
|
||||||
final list = response.data!
|
McTimetableClass.fromJson,
|
||||||
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
),
|
||||||
.toList();
|
);
|
||||||
return TimetableGetClassesResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-10
@@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery {
|
|||||||
required int id,
|
required int id,
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime until,
|
required DateTime until,
|
||||||
}) => guard(() async {
|
}) => getObject(
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
'timetable/${type.pathSegment}/$id',
|
||||||
endpoint('timetable/${type.pathSegment}/$id'),
|
TimetableGetWeekResponse.fromJson,
|
||||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
|
||||||
);
|
);
|
||||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
|
|
||||||
String _format(DateTime d) =>
|
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart';
|
|||||||
class TimetableGetHolidays extends MarianumConnectQuery {
|
class TimetableGetHolidays extends MarianumConnectQuery {
|
||||||
TimetableGetHolidays({super.dio});
|
TimetableGetHolidays({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetHolidaysResponse> run() => guard(() async {
|
Future<TimetableGetHolidaysResponse> run() async =>
|
||||||
final response = await dio.get<List<dynamic>>(
|
TimetableGetHolidaysResponse(
|
||||||
endpoint('timetable/holidays'),
|
result: await getList('timetable/holidays', McHoliday.fromJson),
|
||||||
);
|
);
|
||||||
final list = response.data!
|
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
return TimetableGetHolidaysResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart';
|
|||||||
class TimetableGetRooms extends MarianumConnectQuery {
|
class TimetableGetRooms extends MarianumConnectQuery {
|
||||||
TimetableGetRooms({super.dio});
|
TimetableGetRooms({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetRoomsResponse> run() => guard(() async {
|
Future<TimetableGetRoomsResponse> run() async => TimetableGetRoomsResponse(
|
||||||
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
|
result: await getList('timetable/rooms', McRoom.fromJson),
|
||||||
final list = response.data!
|
);
|
||||||
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
return TimetableGetRoomsResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-6
@@ -4,10 +4,6 @@ import 'timetable_get_schoolyear_response.dart';
|
|||||||
class TimetableGetSchoolyear extends MarianumConnectQuery {
|
class TimetableGetSchoolyear extends MarianumConnectQuery {
|
||||||
TimetableGetSchoolyear({super.dio});
|
TimetableGetSchoolyear({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
|
Future<TimetableGetSchoolyearResponse> run() =>
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
getObject('timetable/schoolyear', TimetableGetSchoolyearResponse.fromJson);
|
||||||
endpoint('timetable/schoolyear'),
|
|
||||||
);
|
|
||||||
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import 'timetable_get_students_response.dart';
|
|||||||
class TimetableGetStudents extends MarianumConnectQuery {
|
class TimetableGetStudents extends MarianumConnectQuery {
|
||||||
TimetableGetStudents({super.dio});
|
TimetableGetStudents({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetStudentsResponse> run() => guard(() async {
|
Future<TimetableGetStudentsResponse> run() async =>
|
||||||
final response = await dio.get<List<dynamic>>(
|
TimetableGetStudentsResponse(
|
||||||
endpoint('timetable/elements/students'),
|
result: await getList(
|
||||||
);
|
'timetable/elements/students',
|
||||||
final list = response.data!
|
McTimetableStudent.fromJson,
|
||||||
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
),
|
||||||
.toList();
|
);
|
||||||
return TimetableGetStudentsResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart';
|
|||||||
class TimetableGetSubjects extends MarianumConnectQuery {
|
class TimetableGetSubjects extends MarianumConnectQuery {
|
||||||
TimetableGetSubjects({super.dio});
|
TimetableGetSubjects({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetSubjectsResponse> run() => guard(() async {
|
Future<TimetableGetSubjectsResponse> run() async =>
|
||||||
final response = await dio.get<List<dynamic>>(
|
TimetableGetSubjectsResponse(
|
||||||
endpoint('timetable/subjects'),
|
result: await getList('timetable/subjects', McSubject.fromJson),
|
||||||
);
|
);
|
||||||
final list = response.data!
|
|
||||||
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
return TimetableGetSubjectsResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart';
|
|||||||
class TimetableGetTeachers extends MarianumConnectQuery {
|
class TimetableGetTeachers extends MarianumConnectQuery {
|
||||||
TimetableGetTeachers({super.dio});
|
TimetableGetTeachers({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetTeachersResponse> run() => guard(() async {
|
Future<TimetableGetTeachersResponse> run() async =>
|
||||||
final response = await dio.get<List<dynamic>>(
|
TimetableGetTeachersResponse(
|
||||||
endpoint('timetable/elements/teachers'),
|
result: await getList(
|
||||||
);
|
'timetable/elements/teachers',
|
||||||
final list = response.data!
|
McTimetableTeacherElement.fromJson,
|
||||||
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
|
),
|
||||||
.toList();
|
);
|
||||||
return TimetableGetTeachersResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart';
|
|||||||
class TimetableGetTimegrid extends MarianumConnectQuery {
|
class TimetableGetTimegrid extends MarianumConnectQuery {
|
||||||
TimetableGetTimegrid({super.dio});
|
TimetableGetTimegrid({super.dio});
|
||||||
|
|
||||||
Future<TimetableGetTimegridResponse> run() => guard(() async {
|
Future<TimetableGetTimegridResponse> run() async =>
|
||||||
final response = await dio.get<List<dynamic>>(
|
TimetableGetTimegridResponse(
|
||||||
endpoint('timetable/timegrid'),
|
result: await getList('timetable/timegrid', McTimegridUnit.fromJson),
|
||||||
);
|
);
|
||||||
final list = response.data!
|
|
||||||
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
return TimetableGetTimegridResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery {
|
|||||||
Future<TimetableGetWeekResponse> run({
|
Future<TimetableGetWeekResponse> run({
|
||||||
required DateTime from,
|
required DateTime from,
|
||||||
required DateTime until,
|
required DateTime until,
|
||||||
}) => guard(() async {
|
}) => getObject(
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
'timetable/me',
|
||||||
endpoint('timetable/me'),
|
TimetableGetWeekResponse.fromJson,
|
||||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
|
||||||
);
|
);
|
||||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
|
||||||
});
|
|
||||||
|
|
||||||
String _format(DateTime d) =>
|
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ import 'user_search_response.dart';
|
|||||||
class UserSearch extends MarianumConnectQuery {
|
class UserSearch extends MarianumConnectQuery {
|
||||||
UserSearch({super.dio});
|
UserSearch({super.dio});
|
||||||
|
|
||||||
Future<UserSearchResponse> run(String query) => guard(() async {
|
Future<UserSearchResponse> run(String query) async => UserSearchResponse(
|
||||||
final response = await dio.get<List<dynamic>>(
|
result: await getList(
|
||||||
endpoint('users/search'),
|
'users/search',
|
||||||
|
McUserSearchResult.fromJson,
|
||||||
queryParameters: {'q': query},
|
queryParameters: {'q': query},
|
||||||
);
|
),
|
||||||
final list = response.data!
|
);
|
||||||
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
return UserSearchResponse(result: list);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:jiffy/jiffy.dart';
|
|
||||||
|
|
||||||
import '../api_request.dart';
|
|
||||||
import '../errors/network_exception.dart';
|
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';
|
||||||
|
import '../http_errors.dart';
|
||||||
|
|
||||||
abstract class MhslApi<T> extends ApiRequest {
|
abstract class MhslApi<T> {
|
||||||
String subpath;
|
String subpath;
|
||||||
MhslApi(this.subpath);
|
MhslApi(this.subpath);
|
||||||
|
|
||||||
http.Response? response;
|
|
||||||
|
|
||||||
Future<http.Response>? request(Uri uri);
|
Future<http.Response>? request(Uri uri);
|
||||||
T assemble(String raw);
|
T assemble(String raw);
|
||||||
|
|
||||||
@@ -24,22 +19,12 @@ abstract class MhslApi<T> extends ApiRequest {
|
|||||||
'https://mhsl.eu/marianum/marianummobile/$subpath',
|
'https://mhsl.eu/marianum/marianummobile/$subpath',
|
||||||
);
|
);
|
||||||
|
|
||||||
final http.Response data;
|
final data = await sendGuarded('mhsl $subpath', () => request(endpoint));
|
||||||
try {
|
if (data == null) {
|
||||||
final raw = await request(endpoint);
|
throw const NetworkException(
|
||||||
if (raw == null) {
|
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
|
||||||
throw const NetworkException(
|
technicalDetails: 'mhsl request returned null',
|
||||||
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
|
);
|
||||||
technicalDetails: 'mhsl request returned null',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
data = raw;
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.statusCode > 299) {
|
if (data.statusCode > 299) {
|
||||||
@@ -55,8 +40,4 @@ abstract class MhslApi<T> extends ApiRequest {
|
|||||||
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e');
|
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static String dateTimeToJson(DateTime time) =>
|
|
||||||
Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss');
|
|
||||||
static DateTime dateTimeFromJson(String time) => DateTime.parse(time);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -256,9 +256,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (totalTabs != _knownTotalTabs) {
|
if (totalTabs != _knownTotalTabs) {
|
||||||
var targetIndex = currentIndex;
|
var targetIndex = currentIndex;
|
||||||
if (_userOnLastTab) {
|
if (_userOnLastTab || currentIndex >= totalTabs) {
|
||||||
targetIndex = totalTabs - 1;
|
|
||||||
} else if (currentIndex >= totalTabs) {
|
|
||||||
targetIndex = totalTabs - 1;
|
targetIndex = totalTabs - 1;
|
||||||
}
|
}
|
||||||
// Replace the controller atomically: a stale index past the new
|
// Replace the controller atomically: a stale index past the new
|
||||||
|
|||||||
@@ -13,12 +13,8 @@ extension IsSameDay on DateTime {
|
|||||||
|
|
||||||
TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute);
|
TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute);
|
||||||
|
|
||||||
bool isSameDateTime(DateTime other) {
|
bool isSameDateTime(DateTime other) =>
|
||||||
var isSameDay = this.isSameDay(other);
|
isSameDay(other) && toTimeOfDay() == other.toTimeOfDay();
|
||||||
var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay());
|
|
||||||
|
|
||||||
return isSameDay && isSameTimeOfDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other);
|
bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -55,6 +55,7 @@ import 'widget/avatar_disk_cache.dart';
|
|||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
import 'widget/downloads/download_tray.dart';
|
import 'widget/downloads/download_tray.dart';
|
||||||
|
import 'widget/emergency/emergency_notice_gate.dart';
|
||||||
import 'widget_data/widget_sync.dart';
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -360,9 +361,10 @@ class _MainState extends State<Main> {
|
|||||||
// would otherwise cover it).
|
// would otherwise cover it).
|
||||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||||
),
|
),
|
||||||
home: LoaderOverlay(
|
home: EmergencyNoticeGate(
|
||||||
child: Breaker(
|
child: LoaderOverlay(
|
||||||
breaker: BreakerArea.global,
|
child: Breaker(
|
||||||
|
breaker: BreakerArea.global,
|
||||||
child: BlocConsumer<AccountBloc, AccountState>(
|
child: BlocConsumer<AccountBloc, AccountState>(
|
||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
@@ -468,6 +470,7 @@ class _MainState extends State<Main> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+12
-14
@@ -196,15 +196,10 @@ class AccountData {
|
|||||||
/// Prefer this over embedding credentials in URLs — error logs and crash
|
/// Prefer this over embedding credentials in URLs — error logs and crash
|
||||||
/// reports often capture the URL but not headers.
|
/// reports often capture the URL but not headers.
|
||||||
String getBasicAuthHeader() {
|
String getBasicAuthHeader() {
|
||||||
if (!isPopulated()) {
|
_requirePopulated();
|
||||||
throw Exception(
|
|
||||||
'AccountData (e.g. username or password) is not initialized!',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Prefer the scoped app password once available; it survives real-password
|
// Prefer the scoped app password once available; it survives real-password
|
||||||
// rotation and is what the push-v2 registration is bound to.
|
// rotation and is what the push-v2 registration is bound to.
|
||||||
final secret = _appPassword ?? _password;
|
return _basicAuth(_appPassword ?? _password!);
|
||||||
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Basic-auth header using the Talk app password — authenticates the
|
/// Basic-auth header using the Talk app password — authenticates the
|
||||||
@@ -212,29 +207,32 @@ class AccountData {
|
|||||||
/// talk password has not been minted yet; callers treat that as a failed
|
/// talk password has not been minted yet; callers treat that as a failed
|
||||||
/// talk registration and retry on the next start.
|
/// talk registration and retry on the next start.
|
||||||
String getTalkBasicAuthHeader() {
|
String getTalkBasicAuthHeader() {
|
||||||
if (!isPopulated()) {
|
_requirePopulated();
|
||||||
throw Exception(
|
|
||||||
'AccountData (e.g. username or password) is not initialized!',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasAppPasswordTalk()) {
|
if (!hasAppPasswordTalk()) {
|
||||||
throw StateError('Talk app password not available yet');
|
throw StateError('Talk app password not available yet');
|
||||||
}
|
}
|
||||||
return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}';
|
return _basicAuth(_appPasswordTalk!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Basic-auth header that always uses the real password. Needed exactly once,
|
/// Basic-auth header that always uses the real password. Needed exactly once,
|
||||||
/// to mint the app password via `core/getapppassword` (an app password cannot
|
/// to mint the app password via `core/getapppassword` (an app password cannot
|
||||||
/// mint another).
|
/// mint another).
|
||||||
String getRealPasswordBasicAuthHeader() {
|
String getRealPasswordBasicAuthHeader() {
|
||||||
|
_requirePopulated();
|
||||||
|
return _basicAuth(_password!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _requirePopulated() {
|
||||||
if (!isPopulated()) {
|
if (!isPopulated()) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
'AccountData (e.g. username or password) is not initialized!',
|
'AccountData (e.g. username or password) is not initialized!',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _basicAuth(String secret) =>
|
||||||
|
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
||||||
|
|
||||||
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
|
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
|
||||||
/// header map ready to merge into HTTP request headers.
|
/// header map ready to merge into HTTP request headers.
|
||||||
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
|
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
|
||||||
|
|||||||
@@ -1,18 +1,3 @@
|
|||||||
import 'account_data.dart';
|
|
||||||
|
|
||||||
enum EndpointMode { live, stage }
|
|
||||||
|
|
||||||
class EndpointOptions {
|
|
||||||
Endpoint live;
|
|
||||||
Endpoint? staged;
|
|
||||||
EndpointOptions({required this.live, required this.staged});
|
|
||||||
|
|
||||||
Endpoint get(EndpointMode mode) {
|
|
||||||
if (staged == null || mode == EndpointMode.live) return live;
|
|
||||||
return staged!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Endpoint {
|
class Endpoint {
|
||||||
String domain;
|
String domain;
|
||||||
String path;
|
String path;
|
||||||
@@ -29,16 +14,5 @@ class EndpointData {
|
|||||||
|
|
||||||
EndpointData._construct();
|
EndpointData._construct();
|
||||||
|
|
||||||
EndpointMode getEndpointMode() {
|
Endpoint nextcloud() => Endpoint(domain: 'cloud.marianum-fulda.de');
|
||||||
late String existingName;
|
|
||||||
existingName = AccountData().getUsername();
|
|
||||||
return existingName.startsWith('google')
|
|
||||||
? EndpointMode.stage
|
|
||||||
: EndpointMode.live;
|
|
||||||
}
|
|
||||||
|
|
||||||
Endpoint nextcloud() => EndpointOptions(
|
|
||||||
live: Endpoint(domain: 'cloud.marianum-fulda.de'),
|
|
||||||
staged: Endpoint(domain: 'mhsl.eu', path: '/marianum/marianummobile/cloud'),
|
|
||||||
).get(getEndpointMode());
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,13 +371,7 @@ class PushRenderer {
|
|||||||
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
||||||
|
|
||||||
/// Deterministic non-negative 31-bit id from a string, used when the push
|
/// Deterministic non-negative 31-bit id from a string, used when the push
|
||||||
/// carries no `nid`.
|
/// carries no `nid`. Shares the hash with [stableChatNotificationId] (an
|
||||||
int _fallbackId(String? seed) {
|
/// empty/null seed hashes to 0).
|
||||||
if (seed == null || seed.isEmpty) return 0;
|
int _fallbackId(String? seed) => stableChatNotificationId(seed ?? '');
|
||||||
var hash = 0;
|
|
||||||
for (final unit in seed.codeUnits) {
|
|
||||||
hash = (hash * 31 + unit) & 0x7fffffff;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
/// the server public key and the Nextcloud app password to decrypt pushes
|
/// the server public key and the Nextcloud app password to decrypt pushes
|
||||||
/// while the app is not running.
|
/// while the app is not running.
|
||||||
///
|
///
|
||||||
/// The value reuses the existing app-group id already present in the iOS
|
/// A team-prefixed keychain access group (`$(AppIdentifierPrefix)eu.mhsl…push`)
|
||||||
/// project (`ios/Runner/Runner.entitlements`). Phase 3 must additionally list
|
/// listed under `keychain-access-groups` in both the Runner and the NSE
|
||||||
/// it under `keychain-access-groups` for both the Runner and the NSE target.
|
/// entitlements. The literal `MY55VF3KPG.` prefix is the team's stable
|
||||||
|
/// AppIdentifierPrefix; Runner and NSE share the group because they sign with
|
||||||
|
/// the same team. (App-group ids like `group.*` can't be used here because the
|
||||||
|
/// Xcode-managed profiles only grant `<TeamID>.*` keychain groups.)
|
||||||
/// On Android `groupId` is ignored, so this is a no-op there.
|
/// On Android `groupId` is ignored, so this is a no-op there.
|
||||||
const String kPushKeychainGroup = 'group.eu.mhsl.marianum.mobile.client.widget';
|
const String kPushKeychainGroup =
|
||||||
|
'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push';
|
||||||
|
|
||||||
/// [IOSOptions] used for every push-related secure-storage entry. Uses
|
/// [IOSOptions] used for every push-related secure-storage entry. Uses
|
||||||
/// `first_unlock` accessibility so the NSE can read the key material after the
|
/// `first_unlock` accessibility so the NSE can read the key material after the
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ class PushStatusReport {
|
|||||||
(general.registeredProxyServer?.isNotEmpty ?? false) &&
|
(general.registeredProxyServer?.isNotEmpty ?? false) &&
|
||||||
!proxyEndpointMismatch(general) &&
|
!proxyEndpointMismatch(general) &&
|
||||||
general.lastRegistrationError == null;
|
general.lastRegistrationError == null;
|
||||||
|
|
||||||
|
/// True when no link in the chain is currently broken. Unknown links stay
|
||||||
|
/// permissive (mirroring [readyForTestNotification]) so a not-yet-loaded
|
||||||
|
/// capability or an undetermined OS permission does not flip the at-a-glance
|
||||||
|
/// health icon to red. Drives the compact status indicator in the settings.
|
||||||
|
bool get chainHealthy =>
|
||||||
|
buildPushStatusRows(this).every((row) => row.state != PushCheck.fail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the current push chain state. Settings/capability flags come from
|
/// Collects the current push chain state. Settings/capability flags come from
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ class AppRoutes {
|
|||||||
static bool goToTab(BuildContext context, Modules module) {
|
static bool goToTab(BuildContext context, Modules module) {
|
||||||
final index = AppModule.getBottomBarModules(
|
final index = AppModule.getBottomBarModules(
|
||||||
context,
|
context,
|
||||||
).map((e) => e.module).toList().indexOf(module);
|
).indexWhere((e) => e.module == module);
|
||||||
if (index == -1) return false;
|
if (index == -1) return false;
|
||||||
Main.bottomNavigator.jumpToTab(index);
|
Main.bottomNavigator.jumpToTab(index);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class PendingShare {
|
class PendingShare {
|
||||||
final List<String> filePaths;
|
final List<String> filePaths;
|
||||||
final String? text;
|
final String? text;
|
||||||
@@ -17,12 +19,6 @@ class PendingShare {
|
|||||||
/// fires two `open(url)` requests per share (see ShareViewController), so
|
/// fires two `open(url)` requests per share (see ShareViewController), so
|
||||||
/// the same share can arrive twice on the media stream — receivedAt is
|
/// the same share can arrive twice on the media stream — receivedAt is
|
||||||
/// deliberately ignored here so such duplicates compare equal.
|
/// deliberately ignored here so such duplicates compare equal.
|
||||||
bool contentEquals(PendingShare other) {
|
bool contentEquals(PendingShare other) =>
|
||||||
if (text != other.text) return false;
|
text == other.text && listEquals(filePaths, other.filePaths);
|
||||||
if (filePaths.length != other.filePaths.length) return false;
|
|
||||||
for (var i = 0; i < filePaths.length; i++) {
|
|
||||||
if (filePaths[i] != other.filePaths[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
abstract class DataLoader<TResult> {
|
|
||||||
final Dio dio;
|
|
||||||
DataLoader(this.dio) {
|
|
||||||
dio.options.connectTimeout = const Duration(seconds: 10);
|
|
||||||
dio.options.sendTimeout = const Duration(seconds: 30);
|
|
||||||
dio.options.receiveTimeout = const Duration(seconds: 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<TResult> run() async {
|
|
||||||
final response = await fetch();
|
|
||||||
try {
|
|
||||||
return assemble(
|
|
||||||
DataLoaderResult(
|
|
||||||
json: jsonDecode(response.data!),
|
|
||||||
headers: response.headers.map.map(
|
|
||||||
(key, value) => MapEntry(key, value.join(';')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e, stack) {
|
|
||||||
log('DataLoader assemble failed', error: e, stackTrace: stack);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Response<String>> fetch();
|
|
||||||
TResult assemble(DataLoaderResult data);
|
|
||||||
}
|
|
||||||
|
|
||||||
class DataLoaderResult {
|
|
||||||
final dynamic json;
|
|
||||||
final Map<String, String> headers;
|
|
||||||
|
|
||||||
Map<String, dynamic> asMap() => json as Map<String, dynamic>;
|
|
||||||
List<dynamic> asList() => json as List<dynamic>;
|
|
||||||
List<Map<String, dynamic>> asListOfMaps() =>
|
|
||||||
asList().map((e) => e as Map<String, dynamic>).toList();
|
|
||||||
|
|
||||||
DataLoaderResult({required this.json, required this.headers});
|
|
||||||
}
|
|
||||||
@@ -19,12 +19,5 @@ abstract class LoadableState<TState> with _$LoadableState<TState> {
|
|||||||
String? statusText,
|
String? statusText,
|
||||||
}) = _LoadableState<TState>;
|
}) = _LoadableState<TState>;
|
||||||
|
|
||||||
bool _hasError() => error != null;
|
bool showContent() => data != null;
|
||||||
bool _hasData() => data != null;
|
|
||||||
|
|
||||||
bool showPrimaryLoading() => isLoading && !_hasData();
|
|
||||||
bool showBackgroundLoading() => isLoading && _hasData();
|
|
||||||
bool showErrorBar() => _hasError() && _hasData();
|
|
||||||
bool showError() => _hasError() && !_hasData();
|
|
||||||
bool showContent() => _hasData();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,16 +107,26 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
|
|||||||
var bloc = context.watch<LoadableStateBloc>();
|
var bloc = context.watch<LoadableStateBloc>();
|
||||||
final foreground = bloc.connectionForegroundColor(context);
|
final foreground = bloc.connectionForegroundColor(context);
|
||||||
|
|
||||||
return Row(
|
// liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
// Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
|
||||||
children: [
|
// Label getragen, sonst liest der Screenreader ihn doppelt.
|
||||||
Icon(bloc.connectionIcon(), size: 14, color: foreground),
|
return Semantics(
|
||||||
const SizedBox(width: 10),
|
liveRegion: true,
|
||||||
Text(
|
container: true,
|
||||||
bloc.connectionText(lastUpdated: widget.lastUpdated),
|
label: bloc.connectionText(lastUpdated: widget.lastUpdated),
|
||||||
style: TextStyle(fontSize: 12, color: foreground),
|
child: ExcludeSemantics(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(bloc.connectionIcon(), size: 14, color: foreground),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
bloc.connectionText(lastUpdated: widget.lastUpdated),
|
||||||
|
style: TextStyle(fontSize: 12, color: foreground),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-10
@@ -123,23 +123,28 @@ abstract class LoadableHydratedBloc<
|
|||||||
fetch();
|
fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
|
||||||
|
/// Does not guard [isClosed] — callers decide whether a late error still
|
||||||
|
/// applies.
|
||||||
|
void addLoadingError(Object e) => add(
|
||||||
|
Error(
|
||||||
|
LoadingError(
|
||||||
|
message: errorToUserMessage(e),
|
||||||
|
technicalDetails: errorToTechnicalDetails(e),
|
||||||
|
allowRetry: errorAllowsRetry(e),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
void fetch() {
|
void fetch() {
|
||||||
log('Fetching data for ${TState.toString()}');
|
log('Fetching data for ${TState.toString()}');
|
||||||
gatherData()
|
gatherData()
|
||||||
.catchError((e) {
|
.catchError((Object e) {
|
||||||
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
||||||
// The bloc may have been closed before this async error landed;
|
// The bloc may have been closed before this async error landed;
|
||||||
// adding to a closed bloc throws, so swallow that case.
|
// adding to a closed bloc throws, so swallow that case.
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
add(
|
addLoadingError(e);
|
||||||
Error(
|
|
||||||
LoadingError(
|
|
||||||
message: errorToUserMessage(e),
|
|
||||||
technicalDetails: errorToTechnicalDetails(e),
|
|
||||||
allowRetry: errorAllowsRetry(e),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.then((value) {
|
.then((value) {
|
||||||
log('Fetch for ${TState.toString()} completed!');
|
log('Fetch for ${TState.toString()} completed!');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
|||||||
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../storage/modules_settings.dart';
|
import '../../../storage/modules_settings.dart';
|
||||||
|
import '../../../view/pages/absence_report/absence_report_view.dart';
|
||||||
import '../../../view/pages/files/files.dart';
|
import '../../../view/pages/files/files.dart';
|
||||||
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
||||||
import '../../../view/pages/holidays/holidays_view.dart';
|
import '../../../view/pages/holidays/holidays_view.dart';
|
||||||
@@ -137,6 +138,12 @@ class AppModule {
|
|||||||
breakerArea: BreakerArea.dates,
|
breakerArea: BreakerArea.dates,
|
||||||
create: MarianumDatesView.new,
|
create: MarianumDatesView.new,
|
||||||
),
|
),
|
||||||
|
Modules.absenceReport: AppModule(
|
||||||
|
Modules.absenceReport,
|
||||||
|
name: 'Krankmeldung',
|
||||||
|
icon: () => Icon(Icons.sick_outlined),
|
||||||
|
create: AbsenceReportView.new,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!showFiltered) {
|
if (!showFiltered) {
|
||||||
@@ -248,6 +255,7 @@ class AppModule {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
|
||||||
onPressed: onVisibleChange,
|
onPressed: onVisibleChange,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isVisible
|
isVisible
|
||||||
@@ -285,4 +293,5 @@ enum Modules {
|
|||||||
gradeAveragesCalculator,
|
gradeAveragesCalculator,
|
||||||
holidays,
|
holidays,
|
||||||
marianumDates,
|
marianumDates,
|
||||||
|
absenceReport,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
import '../../../../../api/marianumcloud/talk/chat/get_chat_history.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
|
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
|
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
import '../../chat_list/bloc/chat_list_bloc.dart';
|
import '../../chat_list/bloc/chat_list_bloc.dart';
|
||||||
@@ -58,7 +57,22 @@ class ChatBloc
|
|||||||
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
|
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic>? toStorage(ChatState state) => state.toJson();
|
Map<String, dynamic>? toStorage(ChatState state) {
|
||||||
|
final response = state.chatResponse;
|
||||||
|
if (response == null ||
|
||||||
|
response.data.length <= _kMaxPersistedMessages) {
|
||||||
|
return state.toJson();
|
||||||
|
}
|
||||||
|
// Keep only the newest N; trimming drops older messages, so there is
|
||||||
|
// definitely more history to page back in after a restart.
|
||||||
|
final newest = response
|
||||||
|
.sortByTimestamp()
|
||||||
|
.reversed
|
||||||
|
.take(_kMaxPersistedMessages)
|
||||||
|
.toSet();
|
||||||
|
final trimmed = GetChatResponse(newest)..headers = response.headers;
|
||||||
|
return state.copyWith(chatResponse: trimmed, hasMoreOld: true).toJson();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> gatherData() async {
|
Future<void> gatherData() async {
|
||||||
@@ -77,7 +91,16 @@ class ChatBloc
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_stopLongPoll();
|
_stopLongPoll();
|
||||||
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
add(
|
||||||
|
Emit(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
currentToken: token,
|
||||||
|
chatResponse: null,
|
||||||
|
hasMoreOld: true,
|
||||||
|
isLoadingOlder: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
add(RefetchStarted<ChatState>());
|
add(RefetchStarted<ChatState>());
|
||||||
_scheduleLoad(token);
|
_scheduleLoad(token);
|
||||||
}
|
}
|
||||||
@@ -102,6 +125,40 @@ class ChatBloc
|
|||||||
_stopLongPoll();
|
_stopLongPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pages in the next block of messages older than the oldest currently held.
|
||||||
|
/// No-ops when nothing more can be loaded or a load is already in flight.
|
||||||
|
Future<void> loadOlder() async {
|
||||||
|
final state = innerState;
|
||||||
|
if (state == null) return;
|
||||||
|
final token = state.currentToken;
|
||||||
|
if (token.isEmpty) return;
|
||||||
|
if (state.isLoadingOlder || !state.hasMoreOld) return;
|
||||||
|
final response = state.chatResponse;
|
||||||
|
if (response == null) return;
|
||||||
|
final oldestId = _minMessageId(response);
|
||||||
|
if (oldestId <= 0) return;
|
||||||
|
|
||||||
|
add(Emit((s) => s.copyWith(isLoadingOlder: true)));
|
||||||
|
try {
|
||||||
|
final older = await GetChatHistory(
|
||||||
|
chatToken: token,
|
||||||
|
lastKnownMessageId: oldestId,
|
||||||
|
limit: _kOlderPageSize,
|
||||||
|
).run();
|
||||||
|
if (isClosed) return;
|
||||||
|
if ((innerState?.currentToken ?? '') != token) return;
|
||||||
|
if (older == null || older.data.isEmpty) {
|
||||||
|
add(Emit((s) => s.copyWith(hasMoreOld: false, isLoadingOlder: false)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_applyOlderResponse(older);
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Load older messages for $token failed: $e');
|
||||||
|
if (isClosed) return;
|
||||||
|
add(Emit((s) => s.copyWith(isLoadingOlder: false)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendServerReadMarker(String token, int messageId) async {
|
Future<void> sendServerReadMarker(String token, int messageId) async {
|
||||||
try {
|
try {
|
||||||
await SetReadMarker(
|
await SetReadMarker(
|
||||||
@@ -181,17 +238,7 @@ class ChatBloc
|
|||||||
|
|
||||||
if (!stillCurrent()) return;
|
if (!stillCurrent()) return;
|
||||||
|
|
||||||
if (capturedError != null) {
|
if (capturedError != null) addLoadingError(capturedError!);
|
||||||
add(
|
|
||||||
Error(
|
|
||||||
LoadingError(
|
|
||||||
message: errorToUserMessage(capturedError),
|
|
||||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
|
||||||
allowRetry: errorAllowsRetry(capturedError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startLongPoll(String token) {
|
void _startLongPoll(String token) {
|
||||||
@@ -257,7 +304,15 @@ class ChatBloc
|
|||||||
void _applyChatResponse(GetChatResponse incoming) {
|
void _applyChatResponse(GetChatResponse incoming) {
|
||||||
final current = innerState?.chatResponse;
|
final current = innerState?.chatResponse;
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
|
// Initial load: a short first page means there is nothing older to page in.
|
||||||
|
add(
|
||||||
|
DataGathered(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
chatResponse: incoming,
|
||||||
|
hasMoreOld: incoming.data.length >= _kInitialPageSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final byId = <int, GetChatResponseObject>{};
|
final byId = <int, GetChatResponseObject>{};
|
||||||
@@ -272,6 +327,32 @@ class ChatBloc
|
|||||||
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
|
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merges an older history page. Unlike [_applyChatResponse] it keeps the
|
||||||
|
/// current headers — the older page's `x-chat-last-common-read` would regress
|
||||||
|
/// the read-status shown for already-visible messages.
|
||||||
|
void _applyOlderResponse(GetChatResponse older) {
|
||||||
|
final current = innerState?.chatResponse;
|
||||||
|
if (current == null) return;
|
||||||
|
final byId = <int, GetChatResponseObject>{};
|
||||||
|
for (final m in current.data) {
|
||||||
|
byId[m.id] = m;
|
||||||
|
}
|
||||||
|
for (final m in older.data) {
|
||||||
|
byId.putIfAbsent(m.id, () => m);
|
||||||
|
}
|
||||||
|
final merged = GetChatResponse(byId.values.toSet())
|
||||||
|
..headers = current.headers;
|
||||||
|
add(
|
||||||
|
DataGathered(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
chatResponse: merged,
|
||||||
|
hasMoreOld: older.data.length >= _kOlderPageSize,
|
||||||
|
isLoadingOlder: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _maxMessageId(GetChatResponse? response) {
|
int _maxMessageId(GetChatResponse? response) {
|
||||||
if (response == null) return 0;
|
if (response == null) return 0;
|
||||||
var max = 0;
|
var max = 0;
|
||||||
@@ -281,6 +362,16 @@ class ChatBloc
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _minMessageId(GetChatResponse? response) {
|
||||||
|
if (response == null) return 0;
|
||||||
|
var min = 0;
|
||||||
|
for (final m in response.data) {
|
||||||
|
if (m.id <= 0) continue; // skip dummies
|
||||||
|
if (min == 0 || m.id < min) min = m.id;
|
||||||
|
}
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
|
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
|
||||||
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
|
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
|
||||||
GetChatResponseObject? best;
|
GetChatResponseObject? best;
|
||||||
@@ -300,3 +391,16 @@ class ChatBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const _kLongPollLastGivenHeader = 'x-chat-last-given';
|
const _kLongPollLastGivenHeader = 'x-chat-last-given';
|
||||||
|
|
||||||
|
/// Small first page shown on open (keeps the initial request cheap). Must match
|
||||||
|
/// the `limit` in GetChatCache (get_chat_cache.dart).
|
||||||
|
const _kInitialPageSize = 50;
|
||||||
|
|
||||||
|
/// Upper bound on how many messages are persisted via HydratedBloc. The full
|
||||||
|
/// scrolled history stays in memory at runtime; only the newest this-many
|
||||||
|
/// survive a restart (older ones are re-fetchable via scroll-up).
|
||||||
|
const _kMaxPersistedMessages = 500;
|
||||||
|
|
||||||
|
/// Larger block fetched per scroll-up so paging back through history needs
|
||||||
|
/// fewer round trips.
|
||||||
|
const _kOlderPageSize = 200;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ abstract class ChatState with _$ChatState {
|
|||||||
@Default('') String currentToken,
|
@Default('') String currentToken,
|
||||||
GetChatResponse? chatResponse,
|
GetChatResponse? chatResponse,
|
||||||
int? referenceMessageId,
|
int? referenceMessageId,
|
||||||
|
@Default(true) bool hasMoreOld,
|
||||||
|
@Default(false) bool isLoadingOlder,
|
||||||
}) = _ChatState;
|
}) = _ChatState;
|
||||||
|
|
||||||
factory ChatState.fromJson(Map<String, Object?> json) =>
|
factory ChatState.fromJson(Map<String, Object?> json) =>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$ChatState {
|
mixin _$ChatState {
|
||||||
|
|
||||||
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId;
|
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; bool get hasMoreOld; bool get isLoadingOlder;
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -28,16 +28,16 @@ $ChatStateCopyWith<ChatState> get copyWith => _$ChatStateCopyWithImpl<ChatState>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
|
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
|
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ abstract mixin class $ChatStateCopyWith<$Res> {
|
|||||||
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl;
|
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
|
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -65,12 +65,14 @@ class _$ChatStateCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
|
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
|
||||||
return _then(_self.copyWith(
|
return _then(_self.copyWith(
|
||||||
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
||||||
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
||||||
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,10 +157,10 @@ return $default(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _ChatState() when $default != null:
|
case _ChatState() when $default != null:
|
||||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||||
return orElse();
|
return orElse();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -176,10 +178,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder) $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _ChatState():
|
case _ChatState():
|
||||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||||
throw StateError('Unexpected subclass');
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -196,10 +198,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _ChatState() when $default != null:
|
case _ChatState() when $default != null:
|
||||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -211,12 +213,14 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _ChatState implements ChatState {
|
class _ChatState implements ChatState {
|
||||||
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId});
|
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId, this.hasMoreOld = true, this.isLoadingOlder = false});
|
||||||
factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json);
|
factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json);
|
||||||
|
|
||||||
@override@JsonKey() final String currentToken;
|
@override@JsonKey() final String currentToken;
|
||||||
@override final GetChatResponse? chatResponse;
|
@override final GetChatResponse? chatResponse;
|
||||||
@override final int? referenceMessageId;
|
@override final int? referenceMessageId;
|
||||||
|
@override@JsonKey() final bool hasMoreOld;
|
||||||
|
@override@JsonKey() final bool isLoadingOlder;
|
||||||
|
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -231,16 +235,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
|
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
|
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -251,7 +255,7 @@ abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Re
|
|||||||
factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl;
|
factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
|
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -268,12 +272,14 @@ class __$ChatStateCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of ChatState
|
/// Create a copy of ChatState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
|
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
|
||||||
return _then(_ChatState(
|
return _then(_ChatState(
|
||||||
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
||||||
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
||||||
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
||||||
as int?,
|
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ _ChatState _$ChatStateFromJson(Map<String, dynamic> json) => _ChatState(
|
|||||||
? null
|
? null
|
||||||
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
|
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
|
||||||
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
|
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
|
||||||
|
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
|
||||||
|
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
||||||
@@ -19,4 +21,6 @@ Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
|||||||
'currentToken': instance.currentToken,
|
'currentToken': instance.currentToken,
|
||||||
'chatResponse': instance.chatResponse,
|
'chatResponse': instance.chatResponse,
|
||||||
'referenceMessageId': instance.referenceMessageId,
|
'referenceMessageId': instance.referenceMessageId,
|
||||||
|
'hasMoreOld': instance.hasMoreOld,
|
||||||
|
'isLoadingOlder': instance.isLoadingOlder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import 'dart:developer';
|
|||||||
|
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
|
||||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
import '../repository/chat_list_repository.dart';
|
import '../repository/chat_list_repository.dart';
|
||||||
@@ -87,17 +85,7 @@ class ChatListBloc
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
capturedError = e;
|
capturedError = e;
|
||||||
}
|
}
|
||||||
if (capturedError != null) {
|
if (capturedError != null) addLoadingError(capturedError!);
|
||||||
add(
|
|
||||||
Error(
|
|
||||||
LoadingError(
|
|
||||||
message: errorToUserMessage(capturedError),
|
|
||||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
|
||||||
allowRetry: errorAllowsRetry(capturedError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
|
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
|
||||||
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
import '../repository/files_repository.dart';
|
import '../repository/files_repository.dart';
|
||||||
@@ -112,16 +110,6 @@ class FilesBloc
|
|||||||
);
|
);
|
||||||
add(DataGathered((s) => s.copyWith(listing: listing)));
|
add(DataGathered((s) => s.copyWith(listing: listing)));
|
||||||
}
|
}
|
||||||
if (capturedError != null) {
|
if (capturedError != null) addLoadingError(capturedError!);
|
||||||
add(
|
|
||||||
Error(
|
|
||||||
LoadingError(
|
|
||||||
message: errorToUserMessage(capturedError),
|
|
||||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
|
||||||
allowRetry: errorAllowsRetry(capturedError),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
import '../repository/ticker_repository.dart';
|
import '../repository/ticker_repository.dart';
|
||||||
@@ -10,9 +8,7 @@ class TickerBloc
|
|||||||
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
|
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
|
||||||
@override
|
@override
|
||||||
Future<void> gatherData() async {
|
Future<void> gatherData() async {
|
||||||
final results = await Future.wait([repo.getTicker(), repo.getNav()]);
|
final (ticker, nav) = await (repo.getTicker(), repo.getNav()).wait;
|
||||||
final ticker = results[0] as TickerResponse;
|
|
||||||
final nav = results[1] as TickerNavResponse;
|
|
||||||
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
|
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ class DevToolsSettings {
|
|||||||
@JsonKey(defaultValue: '')
|
@JsonKey(defaultValue: '')
|
||||||
String marianumConnectCustomUrl;
|
String marianumConnectCustomUrl;
|
||||||
|
|
||||||
|
/// Optional override for the backend-independent emergency-notice source.
|
||||||
|
/// Empty falls back to [emergencyNoticeDefaultUrl].
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
String emergencyNoticeUrl;
|
||||||
|
|
||||||
DevToolsSettings({
|
DevToolsSettings({
|
||||||
required this.showPerformanceOverlay,
|
required this.showPerformanceOverlay,
|
||||||
required this.checkerboardOffscreenLayers,
|
required this.checkerboardOffscreenLayers,
|
||||||
required this.checkerboardRasterCacheImages,
|
required this.checkerboardRasterCacheImages,
|
||||||
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
|
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
|
||||||
this.marianumConnectCustomUrl = '',
|
this.marianumConnectCustomUrl = '',
|
||||||
|
this.emergencyNoticeUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolves the effective base URL, falling back to live when the custom URL
|
// Resolves the effective base URL, falling back to live when the custom URL
|
||||||
@@ -43,6 +49,14 @@ class DevToolsSettings {
|
|||||||
static const String liveUrl = 'https://connect.marianum-fulda.de';
|
static const String liveUrl = 'https://connect.marianum-fulda.de';
|
||||||
static const String betaUrl = 'https://connect-beta.marianum-fulda.de';
|
static const String betaUrl = 'https://connect-beta.marianum-fulda.de';
|
||||||
|
|
||||||
|
/// Compiled-in source for the backend-independent emergency notice. Must live
|
||||||
|
/// on infrastructure that is reachable even when MarianumConnect is down.
|
||||||
|
static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override';
|
||||||
|
|
||||||
|
String? resolveEmergencyNoticeUrl() =>
|
||||||
|
sanitizeCustomUrl(emergencyNoticeUrl) ??
|
||||||
|
sanitizeCustomUrl(emergencyNoticeDefaultUrl);
|
||||||
|
|
||||||
/// `true` in builds where plaintext HTTP custom endpoints are still allowed
|
/// `true` in builds where plaintext HTTP custom endpoints are still allowed
|
||||||
/// (debug, profile). Release builds keep this `false` and the picker
|
/// (debug, profile). Release builds keep this `false` and the picker
|
||||||
/// rejects `http://` entirely.
|
/// rejects `http://` entirely.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson(
|
|||||||
) ??
|
) ??
|
||||||
MarianumConnectEndpoint.live,
|
MarianumConnectEndpoint.live,
|
||||||
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
|
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
|
||||||
|
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
||||||
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
|||||||
'marianumConnectEndpoint':
|
'marianumConnectEndpoint':
|
||||||
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
|
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
|
||||||
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
|
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
|
||||||
|
'emergencyNoticeUrl': instance.emergencyNoticeUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$MarianumConnectEndpointEnumMap = {
|
const _$MarianumConnectEndpointEnumMap = {
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ const _$ModulesEnumMap = {
|
|||||||
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
|
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
|
||||||
Modules.holidays: 'holidays',
|
Modules.holidays: 'holidays',
|
||||||
Modules.marianumDates: 'marianumDates',
|
Modules.marianumDates: 'marianumDates',
|
||||||
|
Modules.absenceReport: 'absenceReport',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ class DownloadManager {
|
|||||||
|
|
||||||
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
|
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
|
||||||
final Map<String, DownloadJob> _byTaskId = {};
|
final Map<String, DownloadJob> _byTaskId = {};
|
||||||
final Map<String, bd.DownloadTask> _taskById = {};
|
|
||||||
|
|
||||||
/// All jobs the user should currently see in the downloads tray/overview:
|
/// All jobs the user should currently see in the downloads tray/overview:
|
||||||
/// everything that is in progress or finished-but-not-yet-opened (failed
|
/// everything that is in progress or finished-but-not-yet-opened (failed
|
||||||
@@ -134,7 +133,6 @@ class DownloadManager {
|
|||||||
)..taskId = task.taskId;
|
)..taskId = task.taskId;
|
||||||
_jobs[remotePath] = job;
|
_jobs[remotePath] = job;
|
||||||
_byTaskId[task.taskId] = job;
|
_byTaskId[task.taskId] = job;
|
||||||
_taskById[task.taskId] = task;
|
|
||||||
_refreshVisible();
|
_refreshVisible();
|
||||||
|
|
||||||
final ok = await bd.FileDownloader().enqueue(task);
|
final ok = await bd.FileDownloader().enqueue(task);
|
||||||
@@ -181,6 +179,29 @@ class DownloadManager {
|
|||||||
final taskId = job.taskId;
|
final taskId = job.taskId;
|
||||||
if (taskId == null || !Platform.isAndroid) return;
|
if (taskId == null || !Platform.isAndroid) return;
|
||||||
final id = _androidNotificationId(taskId);
|
final id = _androidNotificationId(taskId);
|
||||||
|
// background_downloader pushes the completion status to us *before* it posts
|
||||||
|
// the "Fertig" notification, and that post runs through a queue throttled to
|
||||||
|
// one notification per ~300ms. When the file opens immediately (lone
|
||||||
|
// foreground download), our first cancel therefore races ahead of the
|
||||||
|
// notification actually appearing and no-ops — leaving it stuck. Re-cancel
|
||||||
|
// across the throttle window so the notification is caught once it lands.
|
||||||
|
for (final delay in _dismissRetryDelays) {
|
||||||
|
if (delay == Duration.zero) {
|
||||||
|
_cancelNotification(id);
|
||||||
|
} else {
|
||||||
|
Future<void>.delayed(delay, () => _cancelNotification(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _dismissRetryDelays = [
|
||||||
|
Duration.zero,
|
||||||
|
Duration(milliseconds: 350),
|
||||||
|
Duration(milliseconds: 750),
|
||||||
|
Duration(milliseconds: 1500),
|
||||||
|
];
|
||||||
|
|
||||||
|
void _cancelNotification(int id) {
|
||||||
unawaited(
|
unawaited(
|
||||||
NotificationService().flutterLocalNotificationsPlugin
|
NotificationService().flutterLocalNotificationsPlugin
|
||||||
.cancel(id: id)
|
.cancel(id: id)
|
||||||
@@ -217,10 +238,7 @@ class DownloadManager {
|
|||||||
job.status.value = const DownloadCancelled();
|
job.status.value = const DownloadCancelled();
|
||||||
}
|
}
|
||||||
_jobs.remove(job.remotePath);
|
_jobs.remove(job.remotePath);
|
||||||
if (taskId != null) {
|
if (taskId != null) _byTaskId.remove(taskId);
|
||||||
_byTaskId.remove(taskId);
|
|
||||||
_taskById.remove(taskId);
|
|
||||||
}
|
|
||||||
scheduleMicrotask(job.dispose);
|
scheduleMicrotask(job.dispose);
|
||||||
}
|
}
|
||||||
_refreshVisible();
|
_refreshVisible();
|
||||||
@@ -314,10 +332,7 @@ class DownloadManager {
|
|||||||
void _remove(DownloadJob job) {
|
void _remove(DownloadJob job) {
|
||||||
_jobs.remove(job.remotePath);
|
_jobs.remove(job.remotePath);
|
||||||
final taskId = job.taskId;
|
final taskId = job.taskId;
|
||||||
if (taskId != null) {
|
if (taskId != null) _byTaskId.remove(taskId);
|
||||||
_byTaskId.remove(taskId);
|
|
||||||
_taskById.remove(taskId);
|
|
||||||
}
|
|
||||||
_refreshVisible();
|
_refreshVisible();
|
||||||
scheduleMicrotask(job.dispose);
|
scheduleMicrotask(job.dispose);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ class LoginHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) => Column(
|
Widget build(BuildContext context) => Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
Image.asset(
|
// Dekoratives Logo – der Schulname steht direkt darunter als Text.
|
||||||
'assets/logo/icon.png',
|
const ExcludeSemantics(
|
||||||
height: 110,
|
child: Image(
|
||||||
fit: BoxFit.contain,
|
image: AssetImage('assets/logo/icon.png'),
|
||||||
gaplessPlayback: true,
|
height: 110,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ class LoginErrorBanner extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final showDetails = details != null
|
||||||
|
? () => InfoDialog.show(
|
||||||
|
context,
|
||||||
|
details!,
|
||||||
|
copyable: true,
|
||||||
|
title: 'Fehlerdetails',
|
||||||
|
)
|
||||||
|
: null;
|
||||||
return AnimatedSize(
|
return AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
@@ -25,52 +33,56 @@ class LoginErrorBanner extends StatelessWidget {
|
|||||||
? const SizedBox(height: 0, width: double.infinity)
|
? const SizedBox(height: 0, width: double.infinity)
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(top: 14),
|
padding: const EdgeInsets.only(top: 14),
|
||||||
child: Material(
|
child: Semantics(
|
||||||
color: theme.colorScheme.errorContainer.withValues(alpha: 0.6),
|
button: showDetails != null,
|
||||||
borderRadius: BorderRadius.circular(12),
|
label: showDetails != null
|
||||||
child: InkWell(
|
? '${message!}, Fehlerdetails anzeigen'
|
||||||
onTap: details != null
|
: message,
|
||||||
? () => InfoDialog.show(
|
onTap: showDetails,
|
||||||
context,
|
child: ExcludeSemantics(
|
||||||
details!,
|
child: Material(
|
||||||
copyable: true,
|
color: theme.colorScheme.errorContainer.withValues(
|
||||||
title: 'Fehlerdetails',
|
alpha: 0.6,
|
||||||
)
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 10,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(12),
|
||||||
children: [
|
child: InkWell(
|
||||||
Icon(
|
onTap: showDetails,
|
||||||
Icons.error_outline,
|
borderRadius: BorderRadius.circular(12),
|
||||||
size: 20,
|
child: Padding(
|
||||||
color: theme.colorScheme.onErrorContainer,
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Text(
|
Icon(
|
||||||
message!,
|
Icons.error_outline,
|
||||||
style: TextStyle(
|
size: 20,
|
||||||
color: theme.colorScheme.onErrorContainer,
|
color: theme.colorScheme.onErrorContainer,
|
||||||
fontSize: 13,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (details != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.onErrorContainer
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (details != null) ...[
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
size: 20,
|
|
||||||
color: theme.colorScheme.onErrorContainer
|
|
||||||
.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../api/errors/error_mapper.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/absence/absence_classes.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/absence/absence_prefill.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/absence/absence_submit.dart';
|
||||||
|
import '../../../extensions/date_time.dart';
|
||||||
|
import '../../../widget/app_progress_indicator.dart';
|
||||||
|
import '../../../widget/async_action_button.dart';
|
||||||
|
import '../../../widget/demo_restricted.dart';
|
||||||
|
import '../../../widget/focus_behaviour.dart';
|
||||||
|
import '../../../widget/placeholder_view.dart';
|
||||||
|
|
||||||
|
/// Mobile mirror of the public absence-report form: submit-only (no history —
|
||||||
|
/// that lives on the web). Identity/class/phone are prefilled from the backend
|
||||||
|
/// but stay editable; the class list matches the submit validation source.
|
||||||
|
/// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`).
|
||||||
|
class AbsenceReportView extends StatefulWidget {
|
||||||
|
const AbsenceReportView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AbsenceReportView> createState() => _AbsenceReportViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||||
|
static const String _required = 'Dieses Feld ist erforderlich.';
|
||||||
|
|
||||||
|
final TextEditingController _firstName = TextEditingController();
|
||||||
|
final TextEditingController _lastName = TextEditingController();
|
||||||
|
final TextEditingController _phone = TextEditingController();
|
||||||
|
final TextEditingController _note = TextEditingController();
|
||||||
|
final AsyncActionController _submitController = AsyncActionController();
|
||||||
|
|
||||||
|
late Future<void> _init;
|
||||||
|
List<String> _classes = const [];
|
||||||
|
String? _selectedClass;
|
||||||
|
late DateTime _absentFrom;
|
||||||
|
late DateTime _absentUntil;
|
||||||
|
bool _submitted = false;
|
||||||
|
bool _done = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final today = DateUtils.dateOnly(DateTime.now());
|
||||||
|
_absentFrom = today;
|
||||||
|
_absentUntil = today;
|
||||||
|
for (final c in [_firstName, _lastName, _phone, _note]) {
|
||||||
|
c.addListener(_onFieldChanged);
|
||||||
|
}
|
||||||
|
_init = _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in [_firstName, _lastName, _phone, _note]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
_submitController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a submit surfaced errors, re-render on every keystroke so the inline
|
||||||
|
// field errors clear as soon as the offending field is filled.
|
||||||
|
void _onFieldChanged() {
|
||||||
|
if (_submitted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
// Both GETs are independent — fire them together. Prefill is best-effort
|
||||||
|
// (mapped to null on failure), so a classes error still propagates while a
|
||||||
|
// prefill failure never surfaces as an unhandled async error.
|
||||||
|
final classesFuture = AbsenceClasses().run();
|
||||||
|
final prefillFuture = AbsencePrefill().run().then<AbsencePrefillResponse?>(
|
||||||
|
(p) => p,
|
||||||
|
onError: (_) => null,
|
||||||
|
);
|
||||||
|
final classes = await classesFuture;
|
||||||
|
final prefill = await prefillFuture;
|
||||||
|
if (prefill != null) _applyPrefill(prefill, classes);
|
||||||
|
_classes = classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPrefill(AbsencePrefillResponse p, List<String> classes) {
|
||||||
|
if (_firstName.text.isEmpty) _firstName.text = p.firstName;
|
||||||
|
if (_lastName.text.isEmpty) _lastName.text = p.lastName;
|
||||||
|
if (_phone.text.isEmpty) _phone.text = p.phone;
|
||||||
|
if (_selectedClass == null && classes.contains(p.className)) {
|
||||||
|
_selectedClass = p.className;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _startInPast =>
|
||||||
|
_absentFrom.isBefore(DateUtils.dateOnly(DateTime.now()));
|
||||||
|
|
||||||
|
bool get _endBeforeStart => _absentUntil.isBefore(_absentFrom);
|
||||||
|
|
||||||
|
Future<void> _pickFrom() async {
|
||||||
|
final today = DateUtils.dateOnly(DateTime.now());
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _absentFrom.isBefore(today) ? today : _absentFrom,
|
||||||
|
firstDate: today,
|
||||||
|
lastDate: DateTime(today.year + 1, today.month, today.day),
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
setState(() {
|
||||||
|
_absentFrom = picked;
|
||||||
|
if (_endBeforeStart) _absentUntil = _absentFrom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickUntil() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _endBeforeStart ? _absentFrom : _absentUntil,
|
||||||
|
firstDate: _absentFrom,
|
||||||
|
lastDate: DateTime(
|
||||||
|
_absentFrom.year + 1,
|
||||||
|
_absentFrom.month,
|
||||||
|
_absentFrom.day,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
setState(() => _absentUntil = picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (guardDemoAction(context)) return;
|
||||||
|
final valid =
|
||||||
|
_firstName.text.trim().isNotEmpty &&
|
||||||
|
_lastName.text.trim().isNotEmpty &&
|
||||||
|
_selectedClass != null &&
|
||||||
|
_phone.text.trim().isNotEmpty &&
|
||||||
|
_note.text.trim().isNotEmpty &&
|
||||||
|
!_startInPast &&
|
||||||
|
!_endBeforeStart;
|
||||||
|
if (!valid) {
|
||||||
|
setState(() => _submitted = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await AbsenceSubmit().run(
|
||||||
|
firstName: _firstName.text.trim(),
|
||||||
|
lastName: _lastName.text.trim(),
|
||||||
|
className: _selectedClass!,
|
||||||
|
absentFrom: _absentFrom,
|
||||||
|
absentUntil: _absentUntil,
|
||||||
|
phone: _phone.text.trim(),
|
||||||
|
note: _note.text.trim(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
// Replace the whole form with a terminal success screen. There is
|
||||||
|
// deliberately no way back to the form here — to file another report the
|
||||||
|
// user leaves the module and re-enters (which builds a fresh form).
|
||||||
|
setState(() => _done = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Krankmeldung')),
|
||||||
|
body: _done ? const _SubmittedView() : _buildBody(context),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) => FutureBuilder<void>(
|
||||||
|
future: _init,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return PlaceholderView(
|
||||||
|
icon: Icons.error_outline,
|
||||||
|
text: errorToUserMessage(snapshot.error),
|
||||||
|
button: ElevatedButton.icon(
|
||||||
|
onPressed: () => setState(() => _init = _load()),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Erneut versuchen'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildForm(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildForm(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Bitte tragen Sie hier die voraussichtliche Abwesenheit ein. '
|
||||||
|
'Bitte denken Sie auch an die schriftliche Entschuldigung.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextField(
|
||||||
|
controller: _firstName,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: _decoration(
|
||||||
|
'Vorname',
|
||||||
|
error: _requiredError(_firstName),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _lastName,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: _decoration(
|
||||||
|
'Nachname',
|
||||||
|
error: _requiredError(_lastName),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectedClass,
|
||||||
|
isExpanded: true,
|
||||||
|
hint: const Text('— bitte wählen —'),
|
||||||
|
decoration: _decoration(
|
||||||
|
'Klasse',
|
||||||
|
error: _submitted && _selectedClass == null ? _required : null,
|
||||||
|
),
|
||||||
|
items: _classes
|
||||||
|
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) => setState(() => _selectedClass = value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_DateField(
|
||||||
|
label: 'Fehlt ab',
|
||||||
|
value: _absentFrom.formatDate(),
|
||||||
|
error: _submitted && _startInPast
|
||||||
|
? 'Das Startdatum darf nicht in der Vergangenheit liegen.'
|
||||||
|
: null,
|
||||||
|
onTap: _pickFrom,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_DateField(
|
||||||
|
label: 'bis',
|
||||||
|
value: _absentUntil.formatDate(),
|
||||||
|
error: _submitted && _endBeforeStart
|
||||||
|
? 'Das Enddatum darf nicht vor dem Startdatum liegen.'
|
||||||
|
: null,
|
||||||
|
onTap: _pickUntil,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: _decoration(
|
||||||
|
'Telefonnummer für Rückfragen',
|
||||||
|
error: _requiredError(_phone),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _note,
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: 6,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
decoration: _decoration(
|
||||||
|
'Bemerkung / Grund',
|
||||||
|
error: _requiredError(_note),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
AsyncActionButton(
|
||||||
|
controller: _submitController,
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Abwesenheit melden'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _requiredError(TextEditingController controller) =>
|
||||||
|
_submitted && controller.text.trim().isEmpty ? _required : null;
|
||||||
|
|
||||||
|
InputDecoration _decoration(String label, {String? error, String? hint}) =>
|
||||||
|
InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
errorText: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal success screen shown in place of the form after a report was
|
||||||
|
/// submitted. Mirrors the public web form's done page and intentionally offers
|
||||||
|
/// no path back to the form — filing another report means re-entering the
|
||||||
|
/// module.
|
||||||
|
class _SubmittedView extends StatelessWidget {
|
||||||
|
const _SubmittedView();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
size: 60,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Ihre Abwesenheit wurde erfolgreich übermittelt!',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Vielen Dank und bei Krankheit gute Besserung!',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Bei Fragen wenden Sie sich bitte an unser Sekretariat. '
|
||||||
|
'Tel: 0661-969120',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateField extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String? error;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _DateField({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.error,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: label,
|
||||||
|
errorText: error,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.date_range_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _resetProgress() {
|
||||||
|
_isUploading = false;
|
||||||
|
_overallProgressValue = 0.0;
|
||||||
|
_infoText = '';
|
||||||
|
}
|
||||||
|
|
||||||
void _showUploadError(String message) {
|
void _showUploadError(String message) {
|
||||||
setState(() {
|
setState(_resetProgress);
|
||||||
_isUploading = false;
|
|
||||||
_overallProgressValue = 0.0;
|
|
||||||
_infoText = '';
|
|
||||||
});
|
|
||||||
InfoDialog.show(
|
InfoDialog.show(
|
||||||
context,
|
context,
|
||||||
message,
|
message,
|
||||||
@@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
|||||||
|
|
||||||
if (replaceFiles != true) {
|
if (replaceFiles != true) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isUploading = false;
|
_resetProgress();
|
||||||
_overallProgressValue = 0.0;
|
|
||||||
_infoText = '';
|
|
||||||
for (var element in conflictingFiles) {
|
for (var element in conflictingFiles) {
|
||||||
element.isConflicting = true;
|
element.isConflicting = true;
|
||||||
}
|
}
|
||||||
@@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
|
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
|
||||||
setState(() {
|
setState(_resetProgress);
|
||||||
_isUploading = false;
|
|
||||||
_overallProgressValue = 0.0;
|
|
||||||
_infoText = '';
|
|
||||||
});
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
showHttpErrorCode(uploadTask.statusCode);
|
showHttpErrorCode(uploadTask.statusCode);
|
||||||
@@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(_resetProgress);
|
||||||
_isUploading = false;
|
|
||||||
_overallProgressValue = 0.0;
|
|
||||||
_infoText = '';
|
|
||||||
});
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
widget.onUploadFinished(uploadetFilePaths);
|
widget.onUploadFinished(uploadetFilePaths);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
|
|||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) => IconButton(
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Zurück',
|
||||||
onPressed: () => close(context, null),
|
onPressed: () => close(context, null),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user