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
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
/// 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 serverPublicKeyAccount = "push_server_public_key_pem"
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
/// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim
/// 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
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
@@ -8,7 +8,7 @@
</array>
<key>keychain-access-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</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 |
| `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()` |
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
| `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
> 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)
> **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
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**.
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
## 4. Ermittelte Keychain-Details (verbindlich)
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
`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` |
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
| `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`) |
| 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.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
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_app_badge (2.0.0):
- Flutter
- home_widget (0.0.1):
- Flutter
- open_filex (0.0.2):
- Flutter
- PhoneNumberKit (3.7.11):
@@ -14,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore
- receive_sharing_intent (1.8.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
@@ -23,10 +19,8 @@ DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`)
- Flutter (from `Flutter`)
- 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`)
- PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
@@ -40,12 +34,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_app_badge:
:path: ".symlinks/plugins/flutter_app_badge/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
@@ -53,10 +43,8 @@ SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
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, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.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 */; };
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 */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
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, ); }; };
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
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 */
/* Begin PBXContainerItemProxy section */
@@ -37,6 +41,13 @@
remoteGlobalIDString = AA0101010000000011111111;
remoteInfo = TimetableWidgetExtension;
};
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
remoteInfo = NotificationServiceExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -48,6 +59,7 @@
files = (
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -65,17 +77,24 @@
/* End PBXCopyFilesBuildPhase 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>"; };
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; };
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>"; };
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; };
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>"; };
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>"; };
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>"; };
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>"; };
@@ -94,10 +113,12 @@
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>"; };
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>"; };
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>"; };
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 */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -169,6 +190,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -185,15 +214,36 @@
path = Pods;
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 */ = {
isa = PBXGroup;
children = (
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
isa = PBXGroup;
children = (
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -208,6 +258,9 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
@@ -222,6 +275,7 @@
97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */,
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
);
sourceTree = "<group>";
};
@@ -231,6 +285,7 @@
97C146EE1CF9000F007C117D /* Runner.app */,
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -278,9 +333,6 @@
productType = "com.apple.product-type.app-extension";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -299,8 +351,12 @@
dependencies = (
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
AA0101090000000011111111 /* PBXTargetDependency */,
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -325,13 +381,27 @@
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
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 */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -360,6 +430,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -367,11 +440,19 @@
97C146ED1CF9000F007C117D /* Runner */,
3321F8042FB1C00C0011C712 /* Share Extension */,
AA0101010000000011111111 /* TimetableWidgetExtension */,
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
239068341DC6E6B36193EC96 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3321F8032FB1C00C0011C712 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -520,6 +601,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
DF8C1170E7DF96711030EAC0 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -528,6 +618,12 @@
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
};
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationServiceExtension;
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
};
AA0101090000000011111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
@@ -929,6 +1025,29 @@
};
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 */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
@@ -1051,6 +1170,54 @@
};
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 */
/* Begin XCConfigurationList section */
@@ -1094,13 +1261,25 @@
defaultConfigurationIsVisible = 0;
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 */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.1"
}
},
{
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
"version" : "12.13.0"
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.15.0"
}
},
{
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
"version" : "3.5.0"
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.6.1"
}
},
{
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
"version" : "12.13.0"
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.15.0"
}
},
{
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.2"
}
},
{
@@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
"version" : "1.22.2"
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
@@ -131,8 +131,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
@@ -140,8 +140,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
+1 -1
View File
@@ -12,7 +12,7 @@ import UserNotifications
private let markReadActionId = "TALK_MARK_READ"
// 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 appPasswordAccount = "nextcloud_app_password"
private let baseUrlAccount = "nextcloud_base_url"
+1 -1
View File
@@ -11,7 +11,7 @@
</array>
<key>keychain-access-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
+2 -2
View File
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
)
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
open: context.url,
options: [:]
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
import AVFoundation
// 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 {
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_holidays.dart';
import 'data/demo_timetable.dart';
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
.toList();
case 'breaker':
return DemoBreaker.none().toJson();
case 'absence/classes':
return DemoAbsence.classes();
case 'absence/prefill':
return DemoAbsence.prefill().toJson();
case 'timetable/elements/teachers':
case 'timetable/elements/students':
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 '../api_error.dart';
import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart';
import 'app_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).
AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode;
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
switch (status) {
case 401:
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 'package:http/http.dart' as http;
@@ -38,9 +37,6 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return AutocompleteResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
}
}
@@ -1,18 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_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/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces:
@@ -42,30 +37,17 @@ Future<http.Response> _send(
) async {
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await 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 response = (await sendGuarded(
'Cloud $uri',
() => perform(uri, headers),
))!;
final status = response.statusCode;
if (status >= 200 && status < 300) return response;
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Cloud $uri -> HTTP $status'
: 'Cloud $uri -> HTTP $status body=$preview';
final detail = httpErrorDetail('Cloud $uri', response.body, status);
log(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);
throwForStatus(status, detail);
}
class SetUserAvatar {
+7
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart';
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
class 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() => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -28,9 +27,7 @@ class SearchFiles {
'Files search failed with ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data);
}
}
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
@override
GetChatResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
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(),
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 '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -41,24 +37,17 @@ class LongPollChat {
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.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}');
}
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
@@ -12,12 +11,8 @@ class ClosePoll extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'create_room_params.dart';
import 'create_room_response.dart';
@@ -13,10 +12,8 @@ class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoom(this.params) : super('v4/room', params);
@override
CreateRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
CreateRoomResponse assemble(String raw) =>
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_participants_response.dart';
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
GetParticipants(this.token) : super('v4/room/$token/participants', null);
@override
GetParticipantsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetParticipantsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_poll_state_response.dart';
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,9 +1,8 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_reactions_response.dart';
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
: super('v1/reaction/$chatToken/$messageId', null);
@override
GetReactionsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetReactionsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
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 '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_room_params.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());
@override
GetRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
+13 -40
View File
@@ -1,29 +1,20 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../api_params.dart';
import '../../api_request.dart';
import '../../api_response.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
enum TalkApiMethod { get, post, put, delete }
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
abstract class TalkApi<T extends ApiResponse?> {
String path;
ApiParams? body;
Map<String, String>? headers;
Map<String, dynamic>? getParameters;
http.Response? response;
TalkApi(this.path, this.body, {this.headers, this.getParameters});
Future<http.Response>? request(
@@ -40,22 +31,15 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
);
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
final http.Response data;
try {
final raw = await request(endpoint, body, mergedHeaders);
if (raw == null) {
throw const NetworkException(
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 data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
);
if (data == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
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,
// removed participant, ...); include a trimmed preview so the dialog and
// logs surface the cause instead of just the bare status code.
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Talk $endpoint -> HTTP $status'
: 'Talk $endpoint -> HTTP $status body=$preview';
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
log(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);
throwForStatus(status, detail);
}
try {
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
import 'vote_poll_params.dart';
@@ -22,12 +23,8 @@ class VotePoll extends TalkApi<GetPollStateResponse> {
);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response>? request(
+1 -2
View File
@@ -2,10 +2,9 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../api_request.dart';
import '../../api_response.dart';
abstract class WebdavApi<T> extends ApiRequest {
abstract class WebdavApi<T> {
T genericParams;
WebdavApi(this.genericParams) {
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
class MarianumConnectApi {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 20);
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
static final Dio _instance = _build();
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() {
final dio = Dio(
BaseOptions(
@@ -26,4 +26,36 @@ abstract class MarianumConnectQuery {
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 '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.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
/// would attempt to re-auth us into a loop if our credentials are wrong.
class AuthLogin extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
AuthLogin({
@@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery {
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String username,
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
/// 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
/// and obscure a real 401 with a silent re-login.
class AuthVerify extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
AuthVerify({
@@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery {
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] on 401 (credentials no longer match the token's
/// user, token missing, or token rejected), other [AppException]s on
@@ -7,8 +7,6 @@ import 'get_breakers_response.dart';
class GetBreakers extends MarianumConnectQuery {
GetBreakers({super.dio});
Future<GetBreakersResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
return GetBreakersResponse.fromJson(response.data!);
});
Future<GetBreakersResponse> run() =>
getObject('breaker', GetBreakersResponse.fromJson);
}
@@ -7,10 +7,6 @@ import 'get_capabilities_response.dart';
class GetCapabilities extends MarianumConnectQuery {
GetCapabilities({super.dio});
Future<CapabilitiesResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
});
Future<CapabilitiesResponse> run() =>
getObject('me/capabilities', CapabilitiesResponse.fromJson);
}
@@ -4,10 +4,5 @@ import '../../models/mc_holiday.dart';
class GetHolidays extends MarianumConnectQuery {
GetHolidays({super.dio});
Future<List<McHoliday>> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
return response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<List<McHoliday>> run() => getList('holidays', McHoliday.fromJson);
}
@@ -6,8 +6,6 @@ import 'get_ticker_response.dart';
class GetTicker extends MarianumConnectQuery {
GetTicker({super.dio});
Future<TickerResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
return TickerResponse.fromJson(response.data!);
});
Future<TickerResponse> run() =>
getObject('ticker', TickerResponse.fromJson);
}
@@ -6,10 +6,6 @@ import 'get_ticker_nav_response.dart';
class GetTickerNav extends MarianumConnectQuery {
GetTickerNav({super.dio});
Future<TickerNavResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
});
Future<TickerNavResponse> run() =>
getObject('ticker/pages', TickerNavResponse.fromJson);
}
@@ -4,10 +4,8 @@ import '../../marianumconnect_query.dart';
class TimetableCustomEventsGet extends MarianumConnectQuery {
TimetableCustomEventsGet({super.dio});
Future<GetCustomTimetableEventResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/custom-events'),
);
return GetCustomTimetableEventResponse.fromJson(response.data!);
});
Future<GetCustomTimetableEventResponse> run() => getObject(
'timetable/custom-events',
GetCustomTimetableEventResponse.fromJson,
);
}
@@ -4,13 +4,11 @@ import 'timetable_get_classes_response.dart';
class TimetableGetClasses extends MarianumConnectQuery {
TimetableGetClasses({super.dio});
Future<TimetableGetClassesResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/classes'),
);
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
});
Future<TimetableGetClassesResponse> run() async =>
TimetableGetClassesResponse(
result: await getList(
'timetable/elements/classes',
McTimetableClass.fromJson,
),
);
}
@@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery {
required int id,
required DateTime from,
required DateTime until,
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/${type.pathSegment}/$id'),
queryParameters: {'from': _format(from), 'until': _format(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')}';
}) => getObject(
'timetable/${type.pathSegment}/$id',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
@@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays extends MarianumConnectQuery {
TimetableGetHolidays({super.dio});
Future<TimetableGetHolidaysResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/holidays'),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
});
Future<TimetableGetHolidaysResponse> run() async =>
TimetableGetHolidaysResponse(
result: await getList('timetable/holidays', McHoliday.fromJson),
);
}
@@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart';
class TimetableGetRooms extends MarianumConnectQuery {
TimetableGetRooms({super.dio});
Future<TimetableGetRoomsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
final list = response.data!
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
});
Future<TimetableGetRoomsResponse> run() async => TimetableGetRoomsResponse(
result: await getList('timetable/rooms', McRoom.fromJson),
);
}
@@ -4,10 +4,6 @@ import 'timetable_get_schoolyear_response.dart';
class TimetableGetSchoolyear extends MarianumConnectQuery {
TimetableGetSchoolyear({super.dio});
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
});
Future<TimetableGetSchoolyearResponse> run() =>
getObject('timetable/schoolyear', TimetableGetSchoolyearResponse.fromJson);
}
@@ -4,13 +4,11 @@ import 'timetable_get_students_response.dart';
class TimetableGetStudents extends MarianumConnectQuery {
TimetableGetStudents({super.dio});
Future<TimetableGetStudentsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/students'),
);
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
});
Future<TimetableGetStudentsResponse> run() async =>
TimetableGetStudentsResponse(
result: await getList(
'timetable/elements/students',
McTimetableStudent.fromJson,
),
);
}
@@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects extends MarianumConnectQuery {
TimetableGetSubjects({super.dio});
Future<TimetableGetSubjectsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/subjects'),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
});
Future<TimetableGetSubjectsResponse> run() async =>
TimetableGetSubjectsResponse(
result: await getList('timetable/subjects', McSubject.fromJson),
);
}
@@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers extends MarianumConnectQuery {
TimetableGetTeachers({super.dio});
Future<TimetableGetTeachersResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/teachers'),
);
final list = response.data!
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTeachersResponse(result: list);
});
Future<TimetableGetTeachersResponse> run() async =>
TimetableGetTeachersResponse(
result: await getList(
'timetable/elements/teachers',
McTimetableTeacherElement.fromJson,
),
);
}
@@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid extends MarianumConnectQuery {
TimetableGetTimegrid({super.dio});
Future<TimetableGetTimegridResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/timegrid'),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
});
Future<TimetableGetTimegridResponse> run() async =>
TimetableGetTimegridResponse(
result: await getList('timetable/timegrid', McTimegridUnit.fromJson),
);
}
@@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery {
Future<TimetableGetWeekResponse> run({
required DateTime from,
required DateTime until,
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/me'),
queryParameters: {'from': _format(from), 'until': _format(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')}';
}) => getObject(
'timetable/me',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
@@ -7,14 +7,11 @@ import 'user_search_response.dart';
class UserSearch extends MarianumConnectQuery {
UserSearch({super.dio});
Future<UserSearchResponse> run(String query) => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('users/search'),
Future<UserSearchResponse> run(String query) async => UserSearchResponse(
result: await getList(
'users/search',
McUserSearchResult.fromJson,
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:io';
import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart';
import '../api_request.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import '../http_errors.dart';
abstract class MhslApi<T> extends ApiRequest {
abstract class MhslApi<T> {
String subpath;
MhslApi(this.subpath);
http.Response? response;
Future<http.Response>? request(Uri uri);
T assemble(String raw);
@@ -24,22 +19,12 @@ abstract class MhslApi<T> extends ApiRequest {
'https://mhsl.eu/marianum/marianummobile/$subpath',
);
final http.Response data;
try {
final raw = await request(endpoint);
if (raw == null) {
throw const NetworkException(
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}');
final data = await sendGuarded('mhsl $subpath', () => request(endpoint));
if (data == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
technicalDetails: 'mhsl request returned null',
);
}
if (data.statusCode > 299) {
@@ -55,8 +40,4 @@ abstract class MhslApi<T> extends ApiRequest {
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) {
var targetIndex = currentIndex;
if (_userOnLastTab) {
targetIndex = totalTabs - 1;
} else if (currentIndex >= totalTabs) {
if (_userOnLastTab || currentIndex >= totalTabs) {
targetIndex = totalTabs - 1;
}
// 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);
bool isSameDateTime(DateTime other) {
var isSameDay = this.isSameDay(other);
var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay());
return isSameDay && isSameTimeOfDay;
}
bool isSameDateTime(DateTime other) =>
isSameDay(other) && toTimeOfDay() == other.toTimeOfDay();
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/debug/cache_view.dart';
import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart';
import 'widget_data/widget_sync.dart';
Future<void> main() async {
@@ -360,9 +361,10 @@ class _MainState extends State<Main> {
// would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
home: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
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
/// reports often capture the URL but not headers.
String getBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
_requirePopulated();
// Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to.
final secret = _appPassword ?? _password;
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
return _basicAuth(_appPassword ?? _password!);
}
/// 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 registration and retry on the next start.
String getTalkBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
_requirePopulated();
if (!hasAppPasswordTalk()) {
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,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
String getRealPasswordBasicAuthHeader() {
_requirePopulated();
return _basicAuth(_password!);
}
void _requirePopulated() {
if (!isPopulated()) {
throw Exception(
'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
/// header map ready to merge into HTTP request headers.
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 {
String domain;
String path;
@@ -29,16 +14,5 @@ class EndpointData {
EndpointData._construct();
EndpointMode getEndpointMode() {
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());
Endpoint nextcloud() => Endpoint(domain: 'cloud.marianum-fulda.de');
}
+3 -9
View File
@@ -371,13 +371,7 @@ class PushRenderer {
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`.
int _fallbackId(String? seed) {
if (seed == null || seed.isEmpty) return 0;
var hash = 0;
for (final unit in seed.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
/// carries no `nid`. Shares the hash with [stableChatNotificationId] (an
/// empty/null seed hashes to 0).
int _fallbackId(String? seed) => stableChatNotificationId(seed ?? '');
}
+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
/// while the app is not running.
///
/// The value reuses the existing app-group id already present in the iOS
/// project (`ios/Runner/Runner.entitlements`). Phase 3 must additionally list
/// it under `keychain-access-groups` for both the Runner and the NSE target.
/// A team-prefixed keychain access group (`$(AppIdentifierPrefix)eu.mhsl…push`)
/// listed under `keychain-access-groups` in both the Runner and the NSE
/// 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.
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
/// `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) &&
!proxyEndpointMismatch(general) &&
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
+1 -1
View File
@@ -440,7 +440,7 @@ class AppRoutes {
static bool goToTab(BuildContext context, Modules module) {
final index = AppModule.getBottomBarModules(
context,
).map((e) => e.module).toList().indexOf(module);
).indexWhere((e) => e.module == module);
if (index == -1) return false;
Main.bottomNavigator.jumpToTab(index);
return true;
+4 -8
View File
@@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
class PendingShare {
final List<String> filePaths;
final String? text;
@@ -17,12 +19,6 @@ class PendingShare {
/// fires two `open(url)` requests per share (see ShareViewController), so
/// the same share can arrive twice on the media stream — receivedAt is
/// deliberately ignored here so such duplicates compare equal.
bool contentEquals(PendingShare other) {
if (text != other.text) return false;
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;
}
bool contentEquals(PendingShare other) =>
text == other.text && listEquals(filePaths, other.filePaths);
}
@@ -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,
}) = _LoadableState<TState>;
bool _hasError() => error != null;
bool _hasData() => data != null;
bool showPrimaryLoading() => isLoading && !_hasData();
bool showBackgroundLoading() => isLoading && _hasData();
bool showErrorBar() => _hasError() && _hasData();
bool showError() => _hasError() && !_hasData();
bool showContent() => _hasData();
bool showContent() => data != null;
}
@@ -107,16 +107,26 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
return 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),
// liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
// Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
// Label getragen, sonst liest der Screenreader ihn doppelt.
return Semantics(
liveRegion: true,
container: true,
label: bloc.connectionText(lastUpdated: widget.lastUpdated),
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();
}
/// 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() {
log('Fetching data for ${TState.toString()}');
gatherData()
.catchError((e) {
.catchError((Object e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}');
// The bloc may have been closed before this async error landed;
// adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
),
),
);
addLoadingError(e);
})
.then((value) {
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 '../../../routing/app_routes.dart';
import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.dart';
import '../../../view/pages/files/files.dart';
import '../../../view/pages/grade_averages/grade_averages_view.dart';
import '../../../view/pages/holidays/holidays_view.dart';
@@ -137,6 +138,12 @@ class AppModule {
breakerArea: BreakerArea.dates,
create: MarianumDatesView.new,
),
Modules.absenceReport: AppModule(
Modules.absenceReport,
name: 'Krankmeldung',
icon: () => Icon(Icons.sick_outlined),
create: AbsenceReportView.new,
),
};
if (!showFiltered) {
@@ -248,6 +255,7 @@ class AppModule {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
onPressed: onVisibleChange,
icon: Icon(
isVisible
@@ -285,4 +293,5 @@ enum Modules {
gradeAveragesCalculator,
holidays,
marianumDates,
absenceReport,
}
+120 -16
View File
@@ -4,13 +4,12 @@ import 'dart:math' as math;
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/long_poll_chat.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_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_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart';
@@ -58,7 +57,22 @@ class ChatBloc
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
@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
Future<void> gatherData() async {
@@ -77,7 +91,16 @@ class ChatBloc
return;
}
_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>());
_scheduleLoad(token);
}
@@ -102,6 +125,40 @@ class ChatBloc
_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 {
try {
await SetReadMarker(
@@ -181,17 +238,7 @@ class ChatBloc
if (!stillCurrent()) return;
if (capturedError != null) {
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
if (capturedError != null) addLoadingError(capturedError!);
}
void _startLongPoll(String token) {
@@ -257,7 +304,15 @@ class ChatBloc
void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse;
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;
}
final byId = <int, GetChatResponseObject>{};
@@ -272,6 +327,32 @@ class ChatBloc
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) {
if (response == null) return 0;
var max = 0;
@@ -281,6 +362,16 @@ class ChatBloc
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).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best;
@@ -300,3 +391,16 @@ class ChatBloc
}
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,
GetChatResponse? chatResponse,
int? referenceMessageId,
@Default(true) bool hasMoreOld,
@Default(false) bool isLoadingOlder,
}) = _ChatState;
factory ChatState.fromJson(Map<String, Object?> json) =>
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
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
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $ChatStateCopyWith<ChatState> get copyWith => _$ChatStateCopyWithImpl<ChatState>
@override
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)
@override
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
@override
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;
@useResult
$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
/// 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(
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 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) {
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();
}
@@ -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) {
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');
}
@@ -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) {
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;
}
@@ -211,12 +213,14 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
@JsonSerializable()
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);
@override@JsonKey() final String currentToken;
@override final GetChatResponse? chatResponse;
@override final int? referenceMessageId;
@override@JsonKey() final bool hasMoreOld;
@override@JsonKey() final bool isLoadingOlder;
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.
@@ -231,16 +235,16 @@ Map<String, dynamic> toJson() {
@override
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)
@override
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
@override
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;
@override @useResult
$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
/// 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(
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 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
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
);
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
@@ -19,4 +21,6 @@ Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
'currentToken': instance.currentToken,
'chatResponse': instance.chatResponse,
'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 '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_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_event.dart';
import '../repository/chat_list_repository.dart';
@@ -87,17 +85,7 @@ class ChatListBloc
} catch (e) {
capturedError = e;
}
if (capturedError != null) {
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
if (capturedError != null) addLoadingError(capturedError!);
}
/// 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 '../../../../../api/errors/error_mapper.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_event.dart';
import '../repository/files_repository.dart';
@@ -112,16 +110,6 @@ class FilesBloc
);
add(DataGathered((s) => s.copyWith(listing: listing)));
}
if (capturedError != null) {
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
if (capturedError != null) addLoadingError(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_event.dart';
import '../repository/ticker_repository.dart';
@@ -10,9 +8,7 @@ class TickerBloc
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
@override
Future<void> gatherData() async {
final results = await Future.wait([repo.getTicker(), repo.getNav()]);
final ticker = results[0] as TickerResponse;
final nav = results[1] as TickerNavResponse;
final (ticker, nav) = await (repo.getTicker(), repo.getNav()).wait;
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
}
+14
View File
@@ -17,12 +17,18 @@ class DevToolsSettings {
@JsonKey(defaultValue: '')
String marianumConnectCustomUrl;
/// Optional override for the backend-independent emergency-notice source.
/// Empty falls back to [emergencyNoticeDefaultUrl].
@JsonKey(defaultValue: '')
String emergencyNoticeUrl;
DevToolsSettings({
required this.showPerformanceOverlay,
required this.checkerboardOffscreenLayers,
required this.checkerboardRasterCacheImages,
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
this.marianumConnectCustomUrl = '',
this.emergencyNoticeUrl = '',
});
// 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 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
/// (debug, profile). Release builds keep this `false` and the picker
/// rejects `http://` entirely.
+2
View File
@@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson(
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
);
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
'marianumConnectEndpoint':
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
'emergencyNoticeUrl': instance.emergencyNoticeUrl,
};
const _$MarianumConnectEndpointEnumMap = {
+1
View File
@@ -39,4 +39,5 @@ const _$ModulesEnumMap = {
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
Modules.holidays: 'holidays',
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> _byTaskId = {};
final Map<String, bd.DownloadTask> _taskById = {};
/// All jobs the user should currently see in the downloads tray/overview:
/// everything that is in progress or finished-but-not-yet-opened (failed
@@ -134,7 +133,6 @@ class DownloadManager {
)..taskId = task.taskId;
_jobs[remotePath] = job;
_byTaskId[task.taskId] = job;
_taskById[task.taskId] = task;
_refreshVisible();
final ok = await bd.FileDownloader().enqueue(task);
@@ -181,6 +179,29 @@ class DownloadManager {
final taskId = job.taskId;
if (taskId == null || !Platform.isAndroid) return;
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(
NotificationService().flutterLocalNotificationsPlugin
.cancel(id: id)
@@ -217,10 +238,7 @@ class DownloadManager {
job.status.value = const DownloadCancelled();
}
_jobs.remove(job.remotePath);
if (taskId != null) {
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
if (taskId != null) _byTaskId.remove(taskId);
scheduleMicrotask(job.dispose);
}
_refreshVisible();
@@ -314,10 +332,7 @@ class DownloadManager {
void _remove(DownloadJob job) {
_jobs.remove(job.remotePath);
final taskId = job.taskId;
if (taskId != null) {
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
if (taskId != null) _byTaskId.remove(taskId);
_refreshVisible();
scheduleMicrotask(job.dispose);
}
+8 -5
View File
@@ -7,11 +7,14 @@ class LoginHeader extends StatelessWidget {
Widget build(BuildContext context) => Column(
children: [
const SizedBox(height: 40),
Image.asset(
'assets/logo/icon.png',
height: 110,
fit: BoxFit.contain,
gaplessPlayback: true,
// Dekoratives Logo der Schulname steht direkt darunter als Text.
const ExcludeSemantics(
child: Image(
image: AssetImage('assets/logo/icon.png'),
height: 110,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
const SizedBox(height: 20),
const Text(
+53 -41
View File
@@ -18,6 +18,14 @@ class LoginErrorBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final showDetails = details != null
? () => InfoDialog.show(
context,
details!,
copyable: true,
title: 'Fehlerdetails',
)
: null;
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
@@ -25,52 +33,56 @@ class LoginErrorBanner extends StatelessWidget {
? const SizedBox(height: 0, width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: Material(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: details != null
? () => InfoDialog.show(
context,
details!,
copyable: true,
title: 'Fehlerdetails',
)
: null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
child: Semantics(
button: showDetails != null,
label: showDetails != null
? '${message!}, Fehlerdetails anzeigen'
: message,
onTap: showDetails,
child: ExcludeSemantics(
child: Material(
color: theme.colorScheme.errorContainer.withValues(
alpha: 0.6,
),
child: Row(
children: [
Icon(
Icons.error_outline,
size: 20,
color: theme.colorScheme.onErrorContainer,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: showDetails,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
const SizedBox(width: 10),
Expanded(
child: Text(
message!,
style: TextStyle(
child: Row(
children: [
Icon(
Icons.error_outline,
size: 20,
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) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
InfoDialog.show(
context,
message,
@@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (replaceFiles != true) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
_resetProgress();
for (var element in conflictingFiles) {
element.isConflicting = true;
}
@@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
showHttpErrorCode(uploadTask.statusCode);
@@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
}
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
widget.onUploadFinished(uploadetFilePaths);
@@ -33,6 +33,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Zurück',
onPressed: () => close(context, null),
);

Some files were not shown because too many files have changed in this diff Show More