19 Commits

Author SHA1 Message Date
MineTec 32be67426c Merge remote-tracking branch 'origin/develop' into develop 2026-07-26 13:49:05 +02:00
MineTec b957189fd3 implemented absence report module with form validation, prefill support, and API integration 2026-07-26 11:45:48 +02:00
MineTec 624c5512a6 fixed failing test 2026-07-23 14:22:32 +02:00
MineTec 3493cf8617 fixed error on pdf viewer transition 2026-07-23 14:16:33 +02:00
MineTec 75080a2c49 implemented shared items view for Talk chats with tabbed categories and on-scroll pagination 2026-07-19 10:58:02 +02:00
MineTec f3cd7896d9 implemented backward pagination for chat history with on-scroll prefetching 2026-07-17 20:14:47 +02:00
Marianum 101e7c909c bump version to 1.4.0+59 2026-07-17 15:19:26 +02:00
Marianum 429f1e6f96 fix build against updated dependencies: re-add DioExceptionType.transformTimeout case (restored in dio 5.10.0) and rename SwiftReceiveSharingIntentPlugin to ReceiveSharingIntentPlugin (renamed in receive_sharing_intent 1.9.0); refresh Podfile.lock and SPM Package.resolved 2026-07-17 15:19:10 +02:00
Marianum a0c55a811c completed iOS push notification setup: created the NotificationServiceExtension Xcode target programmatically (via xcodeproj), embedded it in Runner, and added its xcconfig base configs; fixed Automatic Signing by switching the NSE↔Runner shared keychain from an app-group to a team-prefixed access group across entitlements, Dart and Swift; fixed the analyzer break from dio 5.9.2 dropping DioExceptionType.transformTimeout 2026-07-17 15:05:34 +02:00
MineTec e625216a90 improved push notification settings with health indicators and optimized notification dismissal 2026-07-16 22:40:33 +02:00
MineTec 8128cede21 refactored teacher display in lesson details to better handle substitutions and duplicates 2026-07-16 21:44:08 +02:00
MineTec e5f7cf0176 updated the ProseMirror table view to conditionally hide the mode toggle toolbar when content fits within the available width, ensuring the toggle is only visible for overflowing tables. 2026-07-16 21:43:52 +02:00
MineTec e349d667d4 implemented a 1-minute in-memory cache for the EmergencyNoticeClient to prevent redundant network requests on app resume 2026-07-16 10:47:51 +02:00
MineTec 7c1f5c06df implemented a backend-independent emergency notice system 2026-07-15 20:30:20 +02:00
MineTec e8c6ac1c65 further improved accessibility across the application and add tooltips to action buttons 2026-07-15 19:29:40 +02:00
MineTec 478f0ff20b implemented comprehensive accessibility (A11y) support across the app 2026-07-13 23:41:47 +02:00
MineTec 8274dd46cd refactored HTTP error handling and streamlined UI components by centralizing shared logic and removing redundant parameters 2026-07-13 22:58:02 +02:00
MineTec d7536ea5d0 remove dead endpoint staging mode (obsoleted by demo mode) 2026-07-13 22:32:38 +02:00
MineTec 398b147c76 simplify: dedupe boilerplate and remove dead code across all layers 2026-07-13 22:22:39 +02:00
181 changed files with 4482 additions and 1824 deletions
+40
View File
@@ -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
View File
@@ -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.13.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.33.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.
-12
View File
@@ -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
+188 -9
View File
@@ -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"
} }
}, },
{ {
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -1 +0,0 @@
class ApiRequest {}
+17
View File
@@ -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',
);
}
+5
View File
@@ -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':
+90
View File
@@ -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;
}
}
+3 -3
View File
@@ -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);
+53
View File
@@ -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 {
+7
View File
@@ -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(
+13 -40
View File
@@ -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(
+1 -2
View File
@@ -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,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);
});
} }
@@ -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);
});
} }
@@ -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);
});
} }
+8 -27
View File
@@ -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
View File
@@ -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
+2 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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 -27
View File
@@ -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());
} }
+3 -9
View File
@@ -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;
}
} }
+8 -4
View File
@@ -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
+7
View File
@@ -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
+1 -1
View File
@@ -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;
+4 -8
View File
@@ -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),
),
],
), ),
], ),
); );
} }
@@ -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!');
+9
View File
@@ -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,
} }
+120 -16
View File
@@ -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)));
} }
+14
View File
@@ -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.
+2
View File
@@ -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 = {
+1
View File
@@ -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',
}; };
+25 -10
View File
@@ -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);
} }
+8 -5
View File
@@ -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(
+53 -41
View File
@@ -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),
],
),
),
);
}
+10 -18
View File
@@ -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