13 Commits

Author SHA1 Message Date
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
157 changed files with 2753 additions and 1737 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 {}
+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(
@@ -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(
@@ -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(
+9 -36
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,43 +31,25 @@ 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) {
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',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
}
final status = data.statusCode;
if (status < 200 || status >= 300) {
// 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')}';
}
@@ -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'),
Future<GetCustomTimetableEventResponse> run() => getObject(
'timetable/custom-events',
GetCustomTimetableEventResponse.fromJson,
);
return GetCustomTimetableEventResponse.fromJson(response.data!);
});
}
@@ -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'),
Future<TimetableGetClassesResponse> run() async =>
TimetableGetClassesResponse(
result: await getList(
'timetable/elements/classes',
McTimetableClass.fromJson,
),
);
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
});
}
@@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery {
required int id,
required 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)},
}) => getObject(
'timetable/${type.pathSegment}/$id',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
});
String _format(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
@@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays extends MarianumConnectQuery {
TimetableGetHolidays({super.dio});
Future<TimetableGetHolidaysResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/holidays'),
Future<TimetableGetHolidaysResponse> run() async =>
TimetableGetHolidaysResponse(
result: await getList('timetable/holidays', McHoliday.fromJson),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
});
}
@@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart';
class TimetableGetRooms extends MarianumConnectQuery {
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'),
Future<TimetableGetStudentsResponse> run() async =>
TimetableGetStudentsResponse(
result: await getList(
'timetable/elements/students',
McTimetableStudent.fromJson,
),
);
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
});
}
@@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects extends MarianumConnectQuery {
TimetableGetSubjects({super.dio});
Future<TimetableGetSubjectsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/subjects'),
Future<TimetableGetSubjectsResponse> run() async =>
TimetableGetSubjectsResponse(
result: await getList('timetable/subjects', McSubject.fromJson),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
});
}
@@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers extends MarianumConnectQuery {
TimetableGetTeachers({super.dio});
Future<TimetableGetTeachersResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/teachers'),
Future<TimetableGetTeachersResponse> run() async =>
TimetableGetTeachersResponse(
result: await getList(
'timetable/elements/teachers',
McTimetableTeacherElement.fromJson,
),
);
final list = response.data!
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTeachersResponse(result: list);
});
}
@@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid extends MarianumConnectQuery {
TimetableGetTimegrid({super.dio});
Future<TimetableGetTimegridResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/timegrid'),
Future<TimetableGetTimegridResponse> run() async =>
TimetableGetTimegridResponse(
result: await getList('timetable/timegrid', McTimegridUnit.fromJson),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
});
}
@@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery {
Future<TimetableGetWeekResponse> run({
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)},
}) => getObject(
'timetable/me',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
});
String _format(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
@@ -7,14 +7,11 @@ import 'user_search_response.dart';
class UserSearch extends MarianumConnectQuery {
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);
});
}
+4 -23
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,23 +19,13 @@ 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) {
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',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
}
if (data.statusCode > 299) {
throw ServerException(
@@ -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);
}
+4 -1
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,7 +361,8 @@ class _MainState extends State<Main> {
// would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
home: LoaderOverlay(
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocConsumer<AccountBloc, AccountState>(
@@ -471,6 +473,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,7 +107,15 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
return Row(
// 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),
@@ -117,6 +125,8 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
style: TextStyle(fontSize: 12, color: foreground),
),
],
),
),
);
}
@@ -123,15 +123,10 @@ abstract class LoadableHydratedBloc<
fetch();
}
void fetch() {
log('Fetching data for ${TState.toString()}');
gatherData()
.catchError((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(
/// 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),
@@ -140,6 +135,16 @@ abstract class LoadableHydratedBloc<
),
),
);
void fetch() {
log('Fetching data for ${TState.toString()}');
gatherData()
.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;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
+1
View File
@@ -248,6 +248,7 @@ class AppModule {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
onPressed: onVisibleChange,
icon: Icon(
isVisible
+1 -13
View File
@@ -4,13 +4,11 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/errors/error_mapper.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';
@@ -181,17 +179,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) {
@@ -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 = {
+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);
}
+5 -2
View File
@@ -7,12 +7,15 @@ class LoginHeader extends StatelessWidget {
Widget build(BuildContext context) => Column(
children: [
const SizedBox(height: 40),
Image.asset(
'assets/logo/icon.png',
// 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(
'Marianum Fulda',
+21 -9
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,18 +33,20 @@ class LoginErrorBanner extends StatelessWidget {
? const SizedBox(height: 0, width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
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),
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,
onTap: showDetails,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -76,6 +86,8 @@ class LoginErrorBanner extends StatelessWidget {
),
),
),
),
),
);
}
}
+8 -16
View File
@@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
);
}
void _showUploadError(String message) {
setState(() {
void _resetProgress() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
}
void _showUploadError(String message) {
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),
);
@@ -239,6 +239,7 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
),
trailing: IconButton(
onPressed: () => copyToClipboard(context, _share.url!),
tooltip: 'Link kopieren',
icon: const Icon(Icons.copy_outlined),
),
),
@@ -127,6 +127,7 @@ class _ShareePickerPageState extends State<ShareePickerPage> {
? null
: IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: () {
_searchController.clear();
_query = '';
+3 -15
View File
@@ -6,7 +6,6 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
@@ -18,7 +17,6 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_trigger.dart';
import '../../../../widget/info_dialog.dart';
import '../../../../widget/prompt_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart';
import '../sharing/share_sheet.dart';
@@ -108,13 +106,6 @@ class _FileElementState extends State<FileElement>
return;
}
if (guardDemoAction(context)) return;
if (EndpointData().getEndpointMode() == EndpointMode.stage) {
InfoDialog.show(
context,
'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!',
);
return;
}
if (isDownloading) {
confirmCancelDownload();
return;
@@ -180,11 +171,9 @@ class _FileElementState extends State<FileElement>
);
}
Future<void> _delete() async {
void _delete() {
if (guardDemoAction(context)) return;
await showDialog<void>(
context: context,
builder: (context) => ConfirmDialog(
ConfirmDialog(
title: 'Element löschen?',
content: 'Das Element wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
@@ -193,8 +182,7 @@ class _FileElementState extends State<FileElement>
await webdav.delete(PathUri.parse(widget.file.path));
widget.refetch();
},
),
);
).asDialog(context);
}
void _showActionSheet() {
@@ -23,6 +23,8 @@ class FilesSortActions extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<bool>(
tooltip:
'Sortierrichtung: ${ascending ? 'aufsteigend' : 'absteigend'}',
icon: Icon(
ascending ? Icons.text_rotate_up : Icons.text_rotation_down,
),
@@ -47,6 +49,8 @@ class FilesSortActions extends StatelessWidget {
onSelected: onDirectionChanged,
),
PopupMenuButton<SortOption>(
tooltip:
'Sortieren nach: ${SortOptions.getOption(currentSort).displayName}',
icon: const Icon(Icons.sort),
itemBuilder: (context) => SortOptions.options.keys
.map(
@@ -166,6 +166,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
? null
: IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: () {
_searchController.clear();
setState(() => _query = '');
@@ -35,6 +35,7 @@ class GradeAveragesListView extends StatelessWidget {
Text(getGradeDisplay(grade)),
const SizedBox(width: 30),
IconButton(
tooltip: 'Note entfernen',
onPressed: () {
bloc.add(DecrementGrade(grade));
},
@@ -49,6 +50,7 @@ class GradeAveragesListView extends StatelessWidget {
),
),
IconButton(
tooltip: 'Note hinzufügen',
onPressed: () {
bloc.add(IncrementGrade(grade));
},
@@ -64,6 +66,7 @@ class GradeAveragesListView extends StatelessWidget {
maintainSize: true,
visible: bloc.canDecrementOrDelete(grade),
child: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete),
onPressed: () {
bloc.add(ResetGrade(grade));
@@ -24,23 +24,19 @@ class GradeAveragesView extends StatelessWidget {
Visibility(
visible: bloc.state.grades.isNotEmpty,
child: IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
tooltip: 'Alle zurücksetzen',
onPressed: () => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () {
bloc.add(ResetAll());
},
),
);
},
onConfirm: () => bloc.add(ResetAll()),
).asDialog(context),
icon: const Icon(Icons.delete_forever),
),
),
PopupMenuButton<bool>(
tooltip:
'Notensystem: ${bloc.isMiddleSchool() ? 'Realschule' : 'Oberstufe'}',
initialValue: bloc.isMiddleSchool(),
icon: const Icon(Icons.more_horiz),
itemBuilder: (context) => [true, false]
@@ -64,17 +60,14 @@ class GradeAveragesView extends StatelessWidget {
.toList(),
onSelected: (isMiddleSchool) {
if (bloc.state.grades.isNotEmpty) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
ConfirmDialog(
title: 'Notensystem wechseln',
content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren',
onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)),
),
);
).asDialog(context);
} else {
bloc.add(GradingSystemChanged(isMiddleSchool));
}
+6 -8
View File
@@ -38,10 +38,13 @@ class HolidaysView extends StatelessWidget {
title: const Text('Schulferien'),
actions: [
IconButton(
tooltip: 'Informationen',
icon: const Icon(Icons.info_outline),
onPressed: showDisclaimer,
),
PopupMenuButton<bool>(
tooltip:
'Vergangene Ferien ${bloc.showPastHolidays() ? 'ausblenden' : 'anzeigen'}',
initialValue: bloc.showPastHolidays(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
@@ -81,9 +84,7 @@ class HolidaysView extends StatelessWidget {
text: 'Keine Schulferien verfügbar',
);
}
return ListViewUtil.fromList<McHoliday>(
holidays,
(holiday) {
return ListViewUtil.fromList<McHoliday>(holidays, (holiday) {
String holidayYear() {
final startYear = holiday.startDate.year;
final endYear = holiday.endDate.year;
@@ -93,9 +94,7 @@ class HolidaysView extends StatelessWidget {
return ListTile(
leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text(
'${holiday.longName} ${holidayYear()}',
),
title: Text('${holiday.longName} ${holidayYear()}'),
subtitle: Text(
'${holiday.startDate.formatDate()} - ${holiday.endDate.formatDate()}',
),
@@ -147,8 +146,7 @@ class HolidaysView extends StatelessWidget {
),
trailing: const Icon(Icons.arrow_right),
);
},
);
});
},
),
);
@@ -27,13 +27,14 @@ class MarianumDatesView extends StatelessWidget {
return keys.map((key) {
final first = byMonth[key]!.first.start;
final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
return _MonthGroup(label: label, events: byMonth[key]!);
}).toList();
}
@override
Widget build(BuildContext context) =>
BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
Widget build(
BuildContext context,
) => BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
@@ -41,6 +42,8 @@ class MarianumDatesView extends StatelessWidget {
title: const Text('Marianum Termine'),
actions: [
PopupMenuButton<bool>(
tooltip:
'Vergangene Termine ${bloc.showPastEvents() ? 'ausblenden' : 'anzeigen'}',
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
@@ -57,9 +60,7 @@ class MarianumDatesView extends StatelessWidget {
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(
e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen',
),
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
],
),
),
@@ -68,6 +69,7 @@ class MarianumDatesView extends StatelessWidget {
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
),
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () {
final events = bloc.getEvents() ?? const <MarianumDate>[];
@@ -117,8 +119,7 @@ class MarianumDatesView extends StatelessWidget {
}
class _MonthGroup {
final String key;
final String label;
final List<MarianumDate> events;
_MonthGroup({required this.key, required this.label, required this.events});
_MonthGroup({required this.label, required this.events});
}
@@ -22,11 +22,16 @@ class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget {
@@ -21,6 +22,7 @@ class MarianumMessageListView extends StatelessWidget {
title: const Text('Marianum Message'),
actions: [
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () {
final list = bloc.state.data?.messageList;
@@ -40,12 +42,9 @@ class MarianumMessageListView extends StatelessWidget {
child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length,
itemBuilder: (context, index) {
var message = state.messageList.messages.toList()[index];
var message = state.messageList.messages[index];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
leading: const CenteredLeading(Icon(Icons.newspaper)),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
import '../../../widget/app_progress_indicator.dart';
@@ -39,20 +38,8 @@ class _MessageViewState extends State<MessageView> {
return SfPdfViewer.memory(
snapshot.data!,
enableHyperlinkNavigation: true,
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}',
confirmButton: 'Öffnen',
onConfirm: () => launchUrl(
Uri.parse(e.uri),
mode: LaunchMode.externalApplication,
),
),
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
ConfirmDialog.openBrowser(context, e.uri),
);
},
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/placeholder_view.dart';
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@@ -22,11 +23,16 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@@ -45,10 +51,7 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
itemBuilder: (_, i) {
final message = matches[i];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
leading: const CenteredLeading(Icon(Icons.newspaper)),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
+7 -1
View File
@@ -1,13 +1,18 @@
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import '../../../../widget/a11y/a11y_labels.dart';
class Roomplan extends StatelessWidget {
const Roomplan({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Raumplan')),
body: PhotoView(
body: Semantics(
image: true,
label: A11yLabels.roomPlan,
child: PhotoView(
imageProvider: Image.asset('assets/img/raumplan.png').image,
minScale: 0.5,
maxScale: 2.0,
@@ -15,5 +20,6 @@ class Roomplan extends StatelessWidget {
color: Theme.of(context).colorScheme.surface,
),
),
),
);
}
+1
View File
@@ -23,6 +23,7 @@ class _OverhangState extends State<Overhang> {
title: const Text('Mehr'),
actions: [
IconButton(
tooltip: 'Einstellungen',
onPressed: () => AppRoutes.openSettings(context),
icon: const Icon(Icons.settings),
),
@@ -86,20 +86,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
trailing: DropdownButton<ChatBackgroundType>(
value: s.type,
icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundType.values
.map(
(e) => DropdownMenuItem<ChatBackgroundType>(
value: e,
child: Row(
children: [
Icon(_typeIcon(e)),
const SizedBox(width: 10),
Text(_typeLabel(e)),
],
items: _iconDropdownItems(
ChatBackgroundType.values,
_typeIcon,
_typeLabel,
),
),
)
.toList(),
onChanged: (e) => _onTypeChanged(context, settings, e!),
),
),
@@ -142,20 +133,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
trailing: DropdownButton<ChatBackgroundFit>(
value: s.fit,
icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundFit.values
.map(
(e) => DropdownMenuItem<ChatBackgroundFit>(
value: e,
child: Row(
children: [
Icon(_fitIcon(e)),
const SizedBox(width: 10),
Text(_fitLabel(e)),
],
items: _iconDropdownItems(
ChatBackgroundFit.values,
_fitIcon,
_fitLabel,
),
),
)
.toList(),
onChanged: (e) {
Haptics.selection();
settings.val(write: true).chatBackgroundSettings.fit =
@@ -280,6 +262,27 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
cs.type = ChatBackgroundType.color;
}
/// Dropdown menu items rendered as an icon + label row, shared by the source
/// and fill-mode pickers.
static List<DropdownMenuItem<T>> _iconDropdownItems<T>(
List<T> values,
IconData Function(T) icon,
String Function(T) label,
) => values
.map(
(e) => DropdownMenuItem<T>(
value: e,
child: Row(
children: [
Icon(icon(e)),
const SizedBox(width: 10),
Text(label(e)),
],
),
),
)
.toList();
IconData _typeIcon(ChatBackgroundType type) => switch (type) {
ChatBackgroundType.pattern => Icons.texture_outlined,
ChatBackgroundType.image => Icons.image_outlined,
@@ -330,7 +333,6 @@ class _Preview extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bubble(
context,
alignment: Alignment.centerLeft,
color: remoteColor,
text: 'Wie gefällt dir der neue Hintergrund?',
@@ -338,7 +340,6 @@ class _Preview extends StatelessWidget {
),
const SizedBox(height: 8),
_bubble(
context,
alignment: Alignment.centerRight,
color: selfColor,
text: 'Sieht richtig gut aus! 🎉',
@@ -352,8 +353,7 @@ class _Preview extends StatelessWidget {
);
}
Widget _bubble(
BuildContext context, {
Widget _bubble({
required Alignment alignment,
required Color color,
required String text,
@@ -75,6 +75,7 @@ class DefaultSettings {
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
emergencyNoticeUrl: '',
),
hapticSettings: HapticSettings(level: HapticLevel.full),
);
@@ -66,6 +66,7 @@ class ModuleSortBody extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Slot entfernen',
icon: const Icon(Icons.remove_circle_outline),
onPressed:
modulesSettings.fixedBottomBarSlots >
@@ -80,6 +81,7 @@ class ModuleSortBody extends StatelessWidget {
),
Text('${modulesSettings.fixedBottomBarSlots}'),
IconButton(
tooltip: 'Slot hinzufügen',
icon: const Icon(Icons.add_circle_outline),
onPressed:
modulesSettings.fixedBottomBarSlots <

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