Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb22a497d | |||
| 889d8f67c5 | |||
| 39c16bd4ea | |||
| b9cb1df473 | |||
| 62fa337188 | |||
| c0dadf8b6e | |||
| ab23422a86 | |||
| 646e2c0451 | |||
| 246cb0f527 | |||
| 4c2e9b47e7 | |||
| 2d690736e3 | |||
| 778c473631 | |||
| 7e9cbcf1e9 | |||
| 32be67426c | |||
| b957189fd3 | |||
| 624c5512a6 | |||
| 3493cf8617 | |||
| 75080a2c49 | |||
| f3cd7896d9 | |||
| 101e7c909c | |||
| 429f1e6f96 | |||
| a0c55a811c |
@@ -65,5 +65,9 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
implementation 'com.android.support:multidex:2.0.1'
|
||||
// Same version as workmanager_android pins — needed to enqueue its
|
||||
// BackgroundWorker from native widget code (the plugin uses
|
||||
// `implementation`, so androidx.work is not exposed transitively).
|
||||
implementation 'androidx.work:work-runtime:2.11.2'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||
*/
|
||||
class TimetableDayWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
class TimetableWeekWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -33,6 +33,8 @@ data class WidgetLesson(
|
||||
val subjectShort: String,
|
||||
val subjectLong: String?,
|
||||
val room: String?,
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is null then) — mapped in Dart.
|
||||
val teacher: String?,
|
||||
val originalTeacher: String?,
|
||||
val status: WidgetLessonStatus,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package eu.mhsl.marianum.mobile.client.widgets
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import dev.fluttercommunity.workmanager.BackgroundWorker
|
||||
|
||||
/**
|
||||
* Enqueues the app's Dart one-off widget refresh from native code, so a
|
||||
* freshly placed widget populates within seconds instead of waiting for the
|
||||
* next periodic slot.
|
||||
*
|
||||
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
|
||||
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
|
||||
* persisted by Workmanager().initialize(); if the app never ran, the worker
|
||||
* fails gracefully and the widget keeps rendering its cached/empty state.
|
||||
*/
|
||||
object WidgetRefreshRequester {
|
||||
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
|
||||
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
|
||||
|
||||
fun enqueueOneOffRefresh(context: Context) {
|
||||
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
|
||||
.build(),
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -736,8 +736,11 @@ object WidgetRenderer {
|
||||
)
|
||||
}
|
||||
|
||||
// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||
// schema bump must land in Dart, Swift (WidgetData.swift) and here
|
||||
// together, or the out-of-sync platform silently renders empty.
|
||||
const val KEY_DAY_DATA = "widget_data_day_v1"
|
||||
const val KEY_WEEK_DATA = "widget_data_week_v1"
|
||||
const val KEY_WEEK_DATA = "widget_data_week_v2"
|
||||
const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
|
||||
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.1–3.2 (Target anlegen, Dateien zuordnen) sind
|
||||
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
|
||||
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
|
||||
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
|
||||
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.3–3.4, 3.6)** —
|
||||
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
|
||||
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
|
||||
> aufgesetzt werden muss).
|
||||
|
||||
### 3.1 Target anlegen
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import workmanager_apple
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@@ -12,7 +13,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"
|
||||
@@ -23,6 +24,19 @@ import UserNotifications
|
||||
) -> Bool {
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
registerTalkCategory()
|
||||
// BGAppRefresh for the home-screen widget. Must all happen before
|
||||
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
|
||||
// plugins only during scene connection, which is past BGTaskScheduler's
|
||||
// registration deadline — registerLaunchHandlers() bridges that gap.
|
||||
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
|
||||
// plugin re-submits the refresh request itself after every run.
|
||||
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
WorkmanagerPlugin.registerPeriodicTask(
|
||||
withIdentifier: "eu.mhsl.marianum.widget.refresh",
|
||||
earliestBeginInSeconds: 1800)
|
||||
WorkmanagerPlugin.registerLaunchHandlers()
|
||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||
// forwards these callbacks to the plugins (firebase_messaging,
|
||||
// flutter_local_notifications). We route Talk actions natively here — the
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart`
|
||||
/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync — the Dart
|
||||
/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the
|
||||
/// review checklist here:
|
||||
/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff)
|
||||
/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff)
|
||||
/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri → Mon)
|
||||
/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11
|
||||
/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11
|
||||
/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04
|
||||
/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11
|
||||
enum TimetableAnchor {
|
||||
/// After 17:00 the user's question shifts from "what's left today" to
|
||||
/// "what's tomorrow", so the day widget rolls forward.
|
||||
static let dayCutoffHour = 17
|
||||
|
||||
static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||
var candidate = calendar.startOfDay(for: now)
|
||||
let shiftToTomorrow =
|
||||
calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar)
|
||||
if shiftToTomorrow {
|
||||
candidate = nextDay(candidate, calendar: calendar)
|
||||
}
|
||||
while isWeekend(candidate, calendar: calendar) {
|
||||
candidate = nextDay(candidate, calendar: calendar)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||
let anchor = resolveDayAnchor(now, calendar: calendar)
|
||||
// Swift weekday: 1 = Sunday … 7 = Saturday → distance back to Monday.
|
||||
let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7
|
||||
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor
|
||||
return calendar.startOfDay(for: monday)
|
||||
}
|
||||
|
||||
/// Future instants at which the rendered anchor can change: each day's
|
||||
/// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted,
|
||||
/// strictly after `now`.
|
||||
static func boundaryDates(
|
||||
from now: Date,
|
||||
horizonDays: Int = 3,
|
||||
calendar: Calendar = .current
|
||||
) -> [Date] {
|
||||
var result: [Date] = []
|
||||
let today = calendar.startOfDay(for: now)
|
||||
for offset in 0...horizonDays {
|
||||
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
|
||||
let midnight = calendar.startOfDay(for: day)
|
||||
if midnight > now { result.append(midnight) }
|
||||
if let cutoff = calendar.date(
|
||||
bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day
|
||||
), cutoff > now {
|
||||
result.append(cutoff)
|
||||
}
|
||||
}
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
/// Derives a day payload from the 14-day week payload — same shape the
|
||||
/// Dart buildDayData produces, since the week payload runs through the
|
||||
/// identical per-day merge/collision pipeline.
|
||||
static func slice(
|
||||
week: WidgetTimetableData,
|
||||
forDay anchor: Date,
|
||||
calendar: Calendar = .current
|
||||
) -> WidgetTimetableData {
|
||||
let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) }
|
||||
// Every v2 week payload carries `days` — the key bump guarantees it.
|
||||
let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) }
|
||||
return WidgetTimetableData(
|
||||
fetchedAt: week.fetchedAt,
|
||||
anchorDate: anchor,
|
||||
lessons: lessons,
|
||||
periods: week.periods,
|
||||
isHoliday: dayInfo?.isHoliday ?? false,
|
||||
holidayName: dayInfo?.holidayName,
|
||||
days: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Week payload re-anchored to the week containing/following `anchorDate`.
|
||||
/// The week view filters its five columns off `anchorDate`, so this alone
|
||||
/// performs the Friday-evening/weekend jump to next week.
|
||||
static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData {
|
||||
WidgetTimetableData(
|
||||
fetchedAt: week.fetchedAt,
|
||||
anchorDate: anchorDate,
|
||||
lessons: week.lessons,
|
||||
periods: week.periods,
|
||||
isHoliday: week.isHoliday,
|
||||
holidayName: week.holidayName,
|
||||
days: week.days
|
||||
)
|
||||
}
|
||||
|
||||
private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool {
|
||||
let weekday = calendar.component(.weekday, from: date)
|
||||
return weekday == 1 || weekday == 7
|
||||
}
|
||||
|
||||
private static func nextDay(_ date: Date, calendar: Calendar) -> Date {
|
||||
let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date
|
||||
return calendar.startOfDay(for: next)
|
||||
}
|
||||
}
|
||||
@@ -100,13 +100,13 @@ struct TimetableDayView: View {
|
||||
|
||||
private func header(data: WidgetTimetableData) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(dayLabel(for: data.anchorDate))
|
||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 4)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -383,6 +383,7 @@ struct TimeGridView: View {
|
||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
||||
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,9 +401,31 @@ func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
func dayLabel(for date: Date) -> String {
|
||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||
/// expensive part and the multi-entry timelines render up to ~8 entries per
|
||||
/// reload. Only touched from WidgetKit's archival rendering, so the shared
|
||||
/// instances are safe.
|
||||
enum WidgetDateFormatters {
|
||||
static let shortDate = make("dd.MM.")
|
||||
static let weekdayShort = make("EE")
|
||||
static let weekdayDate = make("EEEE · dd.MM.")
|
||||
static let time = make("HH:mm")
|
||||
static let dateTime = make("dd.MM. HH:mm")
|
||||
|
||||
private static func make(_ format: String) -> DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = format
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
|
||||
/// entries ahead of time, so wall-clock reads would be wrong for every
|
||||
/// entry after the first.
|
||||
func dayLabel(for date: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let anchor = cal.startOfDay(for: date)
|
||||
if anchor == today {
|
||||
return "Heute · \(shortDate(date))"
|
||||
@@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String {
|
||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||
return "Morgen · \(shortDate(date))"
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.dateFormat = "EEEE · dd.MM."
|
||||
return formatter.string(from: date)
|
||||
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||
}
|
||||
|
||||
func shortDate(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "dd.MM."
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.shortDate.string(from: date)
|
||||
}
|
||||
|
||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
||||
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||
let timeFmt = DateFormatter()
|
||||
timeFmt.locale = Locale(identifier: "de_DE")
|
||||
timeFmt.dateFormat = "HH:mm"
|
||||
if fetchedDay == today {
|
||||
return timeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.time.string(from: fetchedAt)
|
||||
}
|
||||
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||
fetchedDay == yesterday {
|
||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
||||
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||
}
|
||||
let dateTimeFmt = DateFormatter()
|
||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
||||
return dateTimeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Spacer()
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
|
||||
}
|
||||
|
||||
private func weekday(for date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "EE"
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.weekdayShort.string(from: date)
|
||||
}
|
||||
|
||||
private func placeholder(_ message: String) -> some View {
|
||||
|
||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .day)
|
||||
let now = Date()
|
||||
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
||||
// advisory; the "Stand:" label tells the user when data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
// advisory; the boundary entries below keep the rendered day correct
|
||||
// even when no reload is granted, and the "Stand:" label tells the
|
||||
// user when the underlying data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .week)
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
let now = Date()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
||||
themeMode: WidgetDataLoader.themeMode()
|
||||
)
|
||||
}
|
||||
|
||||
/// Day timeline derived from the 14-day week payload, so the widget shows
|
||||
/// the right day even when iOS grants no reload for days.
|
||||
static func dayEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .day) { week, date in
|
||||
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
|
||||
/// into next week from cached data, and the midnight entries keep the
|
||||
/// "Stand:" freshness label honest.
|
||||
static func weekEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .week) { week, date in
|
||||
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared timeline skeleton: one entry now plus one per anchor boundary
|
||||
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
|
||||
/// render — same anchor and same calendar day for the header labels —
|
||||
/// are dropped.
|
||||
private static func entries(
|
||||
now: Date,
|
||||
variant: TimetableVariant,
|
||||
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
|
||||
) -> [TimetableEntry] {
|
||||
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
|
||||
// Logged out, or no v2 week snapshot yet (fresh app update):
|
||||
// fall back to the legacy single-entry payload.
|
||||
return [TimetableEntry.current(variant: variant)]
|
||||
}
|
||||
let theme = WidgetDataLoader.themeMode()
|
||||
let cal = Calendar.current
|
||||
var result: [TimetableEntry] = []
|
||||
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
|
||||
let data = transform(week, date)
|
||||
if let previous = result.last, let previousData = previous.data,
|
||||
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
|
||||
cal.isDate(previous.date, inSameDayAs: date) {
|
||||
continue
|
||||
}
|
||||
result.append(TimetableEntry(
|
||||
date: date,
|
||||
variant: variant,
|
||||
data: data,
|
||||
isLoggedIn: true,
|
||||
themeMode: theme
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
||||
case irregular
|
||||
case teacherChanged
|
||||
case event
|
||||
case duty
|
||||
|
||||
/// Unknown future statuses degrade to `.regular` instead of failing the
|
||||
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
|
||||
/// single new enum value must never blank the widget to the placeholder.
|
||||
init(from decoder: Decoder) throws {
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
self = WidgetLessonStatus(rawValue: raw) ?? .regular
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetLesson: Codable {
|
||||
@@ -18,6 +27,8 @@ struct WidgetLesson: Codable {
|
||||
let subjectShort: String
|
||||
let subjectLong: String?
|
||||
let room: String?
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is nil then) — mapped in Dart.
|
||||
let teacher: String?
|
||||
let originalTeacher: String?
|
||||
let status: WidgetLessonStatus
|
||||
@@ -33,6 +44,12 @@ struct WidgetPeriod: Codable {
|
||||
let virtualEndMinutes: Int
|
||||
}
|
||||
|
||||
struct WidgetDayInfo: Codable {
|
||||
let date: Date
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
}
|
||||
|
||||
struct WidgetTimetableData: Codable {
|
||||
let fetchedAt: Date
|
||||
let anchorDate: Date
|
||||
@@ -40,12 +57,17 @@ struct WidgetTimetableData: Codable {
|
||||
let periods: [WidgetPeriod]
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
/// Week payload (v2) only; optional so day payloads keep decoding.
|
||||
let days: [WidgetDayInfo]?
|
||||
}
|
||||
|
||||
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
|
||||
/// together, or the out-of-sync platform silently blanks to the placeholder.
|
||||
enum WidgetDataKey {
|
||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
static let dayData = "widget_data_day_v1"
|
||||
static let weekData = "widget_data_week_v1"
|
||||
static let weekData = "widget_data_week_v2"
|
||||
static let loggedIn = "widget_data_logged_in_v1"
|
||||
static let themeMode = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||
|
||||
/// Demo fixtures for the absence-report form: the class dropdown and the
|
||||
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
|
||||
/// silently drift the demo shape away from what the queries parse.
|
||||
class DemoAbsence {
|
||||
const DemoAbsence._();
|
||||
|
||||
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
|
||||
|
||||
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
className: '10a',
|
||||
phone: '0123 456789',
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,19 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||
|
||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||
/// demo persona sees every feature (incl. push) as available.
|
||||
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||
/// scroll range stays unlimited (null day counts).
|
||||
class DemoCapabilities {
|
||||
const DemoCapabilities._();
|
||||
|
||||
static CapabilitiesState state() => const CapabilitiesState(
|
||||
viewForeignTimetables: true,
|
||||
pushNotifications: true,
|
||||
timetablePastDays: null,
|
||||
timetableFutureDays: null,
|
||||
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
|
||||
// Feld nicht" (siehe CapabilitiesResponse.userType).
|
||||
userType: 'STUDENT',
|
||||
loaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'data/demo_absence.dart';
|
||||
import 'data/demo_breaker.dart';
|
||||
import 'data/demo_holidays.dart';
|
||||
import 'data/demo_timetable.dart';
|
||||
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
|
||||
.toList();
|
||||
case 'breaker':
|
||||
return DemoBreaker.none().toJson();
|
||||
case 'absence/classes':
|
||||
return DemoAbsence.classes();
|
||||
case 'absence/prefill':
|
||||
return DemoAbsence.prefill().toJson();
|
||||
case 'timetable/elements/teachers':
|
||||
case 'timetable/elements/students':
|
||||
case 'timetable/elements/classes':
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||
@@ -18,10 +19,14 @@ class GetAppPassword {
|
||||
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Returns the freshly minted app password. Throws on any transport or
|
||||
/// protocol error — callers treat push registration as best-effort and swallow
|
||||
/// failures.
|
||||
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||
Future<String> run() async {
|
||||
final response = await _client.get(
|
||||
const label = 'Nextcloud getapppassword';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.get(
|
||||
NextcloudOcs.uri('core/getapppassword'),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
@@ -30,9 +35,13 @@ class GetAppPassword {
|
||||
// this endpoint requires the real password.
|
||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
||||
},
|
||||
);
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final data = (json as Map)['ocs']?['data'];
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../../marianumconnect/auth/device_token_name.dart';
|
||||
|
||||
/// Nextcloud Login Flow v2 (`/index.php/login/v2`): interactive browser login
|
||||
/// that yields an app password. It is the only way to obtain working Nextcloud
|
||||
/// credentials when the account is protected by two-factor authentication —
|
||||
/// Basic auth with the real password is rejected server-side in that case.
|
||||
class LoginFlowApi {
|
||||
final http.Client _client;
|
||||
|
||||
LoginFlowApi({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Starts a new flow. Nextcloud displays the request's User-Agent as the
|
||||
/// token name in the user's security settings, so the device token label is
|
||||
/// sent (`"Marianum Fulda App (Pixel 10)"`).
|
||||
Future<LoginFlowInit> start() async {
|
||||
final userAgent = await DeviceTokenName.resolve();
|
||||
final uri = _initUri();
|
||||
const label = 'Nextcloud login flow init';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.post(
|
||||
uri,
|
||||
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
return LoginFlowInit.fromJson(
|
||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls for the flow result: `null` while the browser login has not been
|
||||
/// completed yet (HTTP 404), the final credentials once it has.
|
||||
Future<LoginFlowCredentials?> poll(LoginFlowInit flow) async {
|
||||
const label = 'Nextcloud login flow poll';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.post(
|
||||
Uri.parse(flow.pollEndpoint),
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: {'token': flow.pollToken},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
return LoginFlowCredentials.fromJson(
|
||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
static Uri _initUri() {
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
return Uri.https(endpoint.domain, '${endpoint.path}/index.php/login/v2');
|
||||
}
|
||||
|
||||
/// Whether the login name reported by the completed flow belongs to the
|
||||
/// account this app session expects — the browser login could have been
|
||||
/// completed with a different Nextcloud account.
|
||||
static bool loginNameMatches({
|
||||
required String expected,
|
||||
required String actual,
|
||||
}) => actual.trim().toLowerCase() == expected.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Response of the flow init call: the URL the user opens in the browser plus
|
||||
/// the token/endpoint pair the app polls until the login is confirmed.
|
||||
class LoginFlowInit {
|
||||
final String loginUrl;
|
||||
final String pollToken;
|
||||
final String pollEndpoint;
|
||||
|
||||
const LoginFlowInit({
|
||||
required this.loginUrl,
|
||||
required this.pollToken,
|
||||
required this.pollEndpoint,
|
||||
});
|
||||
|
||||
factory LoginFlowInit.fromJson(Map<String, dynamic> json) {
|
||||
final poll = json['poll'];
|
||||
final loginUrl = json['login'] as String?;
|
||||
final token = poll is Map ? poll['token'] as String? : null;
|
||||
final endpoint = poll is Map ? poll['endpoint'] as String? : null;
|
||||
if (loginUrl == null || loginUrl.isEmpty) {
|
||||
throw const FormatException('login flow init: missing login url');
|
||||
}
|
||||
if (token == null || token.isEmpty || endpoint == null || endpoint.isEmpty) {
|
||||
throw const FormatException('login flow init: missing poll token/endpoint');
|
||||
}
|
||||
return LoginFlowInit(
|
||||
loginUrl: loginUrl,
|
||||
pollToken: token,
|
||||
pollEndpoint: endpoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials returned once the user confirmed the login in the browser.
|
||||
class LoginFlowCredentials {
|
||||
final String server;
|
||||
final String loginName;
|
||||
final String appPassword;
|
||||
|
||||
const LoginFlowCredentials({
|
||||
required this.server,
|
||||
required this.loginName,
|
||||
required this.appPassword,
|
||||
});
|
||||
|
||||
factory LoginFlowCredentials.fromJson(Map<String, dynamic> json) {
|
||||
final loginName = json['loginName'] as String?;
|
||||
final appPassword = json['appPassword'] as String?;
|
||||
if (loginName == null || loginName.isEmpty) {
|
||||
throw const FormatException('login flow poll: missing loginName');
|
||||
}
|
||||
if (appPassword == null || appPassword.isEmpty) {
|
||||
throw const FormatException('login flow poll: missing appPassword');
|
||||
}
|
||||
return LoginFlowCredentials(
|
||||
server: json['server'] as String? ?? '',
|
||||
loginName: loginName,
|
||||
appPassword: appPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
|
||||
GetChatParams(
|
||||
lookIntoFuture: GetChatParamsSwitch.off,
|
||||
setReadMarker: GetChatParamsSwitch.on,
|
||||
limit: 200,
|
||||
// Small initial page; also the per-chat offline snapshot written to
|
||||
// localstore. Older messages are paged in on scroll-up via
|
||||
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
||||
limit: 50,
|
||||
),
|
||||
).run(),
|
||||
fromJson: GetChatResponse.fromJson,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../errors/server_exception.dart';
|
||||
import '../../../http_errors.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
|
||||
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
||||
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
|
||||
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
|
||||
/// when there are no older messages left — a normal "start of chat" outcome here.
|
||||
/// `setReadMarker=off` so paging into history never moves the read cursor.
|
||||
class GetChatHistory {
|
||||
final String chatToken;
|
||||
final int lastKnownMessageId;
|
||||
final int limit;
|
||||
|
||||
GetChatHistory({
|
||||
required this.chatToken,
|
||||
required this.lastKnownMessageId,
|
||||
required this.limit,
|
||||
});
|
||||
|
||||
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
||||
Future<GetChatResponse?> run() async {
|
||||
final params = GetChatParams(
|
||||
lookIntoFuture: GetChatParamsSwitch.off,
|
||||
lastKnownMessageId: lastKnownMessageId,
|
||||
includeLastKnown: GetChatParamsSwitch.off,
|
||||
setReadMarker: GetChatParamsSwitch.off,
|
||||
limit: limit,
|
||||
);
|
||||
final uri = NextcloudOcs.uri(
|
||||
'apps/spreed/api/v1/chat/$chatToken',
|
||||
queryParameters: params.toJson(),
|
||||
);
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final response = (await sendGuarded(
|
||||
'GetChatHistory $uri',
|
||||
() => http.get(uri, headers: headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status == 304) return null;
|
||||
if (status >= 200 && status < 300) {
|
||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||
..headers = response.headers;
|
||||
}
|
||||
throw ServerException(
|
||||
statusCode: status,
|
||||
technicalDetails: 'GetChatHistory $uri: HTTP $status',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_shared_items_response.dart';
|
||||
|
||||
/// Fetches the messages that shared an item of a given [objectType] in a chat
|
||||
/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using
|
||||
/// the `X-Chat-Last-Given` response header (see
|
||||
/// [GetSharedItemsResponse.lastGivenMessageId]).
|
||||
///
|
||||
/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`,
|
||||
/// `deckcard`, `recording`, `other`.
|
||||
class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
|
||||
GetSharedItems(
|
||||
String token, {
|
||||
required String objectType,
|
||||
int limit = 20,
|
||||
int? lastKnownMessageId,
|
||||
}) : super(
|
||||
'v1/chat/$token/share',
|
||||
null,
|
||||
getParameters: {
|
||||
'objectType': objectType,
|
||||
'limit': limit,
|
||||
'lastKnownMessageId': ?lastKnownMessageId,
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
GetSharedItemsResponse assemble(String raw) =>
|
||||
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.get(uri, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_shared_items_overview_response.dart';
|
||||
|
||||
/// Fetches the latest shared items of every type at once (Talk's
|
||||
/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to
|
||||
/// show and to seed their first page. [limit] caps the items returned per type.
|
||||
class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
|
||||
GetSharedItemsOverview(String token, {int limit = 20})
|
||||
: super(
|
||||
'v1/chat/$token/share/overview',
|
||||
null,
|
||||
getParameters: {'limit': limit},
|
||||
);
|
||||
|
||||
@override
|
||||
GetSharedItemsOverviewResponse assemble(String raw) =>
|
||||
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.get(uri, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import '../../../api_response.dart';
|
||||
import '../chat/get_chat_response.dart';
|
||||
|
||||
/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared
|
||||
/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`,
|
||||
/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the
|
||||
/// message structure.
|
||||
class GetSharedItemsOverviewResponse extends ApiResponse {
|
||||
final Map<String, List<GetChatResponseObject>> itemsByType;
|
||||
|
||||
GetSharedItemsOverviewResponse(this.itemsByType);
|
||||
|
||||
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||
final data = ocs['data'];
|
||||
final result = <String, List<GetChatResponseObject>>{};
|
||||
if (data is Map<String, dynamic>) {
|
||||
for (final entry in data.entries) {
|
||||
final value = entry.value;
|
||||
final raw = switch (value) {
|
||||
List<dynamic> list => list,
|
||||
Map<dynamic, dynamic> map => map.values,
|
||||
_ => const <dynamic>[],
|
||||
};
|
||||
result[entry.key] = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GetChatResponseObject.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return GetSharedItemsOverviewResponse(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../../api_response.dart';
|
||||
import '../chat/get_chat_response.dart';
|
||||
|
||||
/// Response of Talk's shared-items endpoints. The message objects carry the
|
||||
/// same structure as regular chat messages, so we reuse [GetChatResponseObject]
|
||||
/// (the shared file lives in `messageParameters['file']`).
|
||||
///
|
||||
/// The server returns `data` either as a list or as a message-id-keyed map
|
||||
/// depending on version; [fromOcs] normalises both to a list.
|
||||
class GetSharedItemsResponse extends ApiResponse {
|
||||
final List<GetChatResponseObject> items;
|
||||
|
||||
GetSharedItemsResponse(this.items);
|
||||
|
||||
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||
final data = ocs['data'];
|
||||
final raw = switch (data) {
|
||||
List<dynamic> list => list,
|
||||
Map<dynamic, dynamic> map => map.values,
|
||||
_ => const <dynamic>[],
|
||||
};
|
||||
final items = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GetChatResponseObject.fromJson)
|
||||
.toList();
|
||||
return GetSharedItemsResponse(items);
|
||||
}
|
||||
|
||||
/// Offset for the next page, taken from the `X-Chat-Last-Given` header.
|
||||
/// Null when the header is absent (older server) — treat that as "stop".
|
||||
int? get lastGivenMessageId {
|
||||
final value = headers?['x-chat-last-given'];
|
||||
return value == null ? null : int.tryParse(value);
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,31 @@ import '../../api_response.dart';
|
||||
abstract class WebdavApi<T> {
|
||||
T genericParams;
|
||||
|
||||
WebdavApi(this.genericParams) {
|
||||
establishWebdavConnection();
|
||||
}
|
||||
WebdavApi(this.genericParams);
|
||||
|
||||
Future<ApiResponse> run();
|
||||
|
||||
static Future<WebDavClient> webdav = establishWebdavConnection();
|
||||
static Future<WebDavClient>? _webdav;
|
||||
static String? _webdavSecret;
|
||||
|
||||
/// Shared WebDAV client. Rebuilt whenever the effective Nextcloud secret
|
||||
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||
/// authenticating with stale credentials.
|
||||
static Future<WebDavClient> get webdav {
|
||||
final secret = AccountData().getNextcloudSecret();
|
||||
if (_webdav == null || _webdavSecret != secret) {
|
||||
_webdavSecret = secret;
|
||||
_webdav = establishWebdavConnection();
|
||||
}
|
||||
return _webdav!;
|
||||
}
|
||||
|
||||
static Future<WebDavClient> establishWebdavConnection() async =>
|
||||
NextcloudClient(
|
||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||
password: AccountData().getPassword(),
|
||||
// App password preferred — with 2FA the real password is not accepted
|
||||
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
||||
password: AccountData().getNextcloudSecret(),
|
||||
loginName: AccountData().getUsername(),
|
||||
).webdav;
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// GETs the selectable classes for the absence form (`absence/classes`). The
|
||||
/// body is a bare JSON string array, so [getList] (which maps objects) does not
|
||||
/// fit — read the raw list and cast.
|
||||
class AbsenceClasses extends MarianumConnectQuery {
|
||||
AbsenceClasses({super.dio});
|
||||
|
||||
Future<List<String>> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(endpoint('absence/classes'));
|
||||
return response.data!.cast<String>();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'absence_prefill_response.dart';
|
||||
|
||||
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
|
||||
class AbsencePrefill extends MarianumConnectQuery {
|
||||
AbsencePrefill({super.dio});
|
||||
|
||||
Future<AbsencePrefillResponse> run() =>
|
||||
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'absence_prefill_response.g.dart';
|
||||
|
||||
/// Prefill for the absence-report form: identity from LDAP plus the phone
|
||||
/// number from the user's last report (empty strings when unknown).
|
||||
@JsonSerializable()
|
||||
class AbsencePrefillResponse {
|
||||
@JsonKey(defaultValue: '')
|
||||
final String firstName;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String lastName;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String className;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String phone;
|
||||
|
||||
AbsencePrefillResponse({
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.className,
|
||||
required this.phone,
|
||||
});
|
||||
|
||||
factory AbsencePrefillResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$AbsencePrefillResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$AbsencePrefillResponseToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'absence_prefill_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => AbsencePrefillResponse(
|
||||
firstName: json['firstName'] as String? ?? '',
|
||||
lastName: json['lastName'] as String? ?? '',
|
||||
className: json['className'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AbsencePrefillResponseToJson(
|
||||
AbsencePrefillResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'firstName': instance.firstName,
|
||||
'lastName': instance.lastName,
|
||||
'className': instance.className,
|
||||
'phone': instance.phone,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
|
||||
/// Empty identity fields are backfilled from LDAP server-side; validation
|
||||
/// (all fields required, class must exist, no past start date, end >= start)
|
||||
/// also runs server-side and mirrors the client checks.
|
||||
class AbsenceSubmit extends MarianumConnectQuery {
|
||||
AbsenceSubmit({super.dio});
|
||||
|
||||
Future<void> run({
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String className,
|
||||
required DateTime absentFrom,
|
||||
required DateTime absentUntil,
|
||||
required String phone,
|
||||
required String note,
|
||||
}) => guard(() async {
|
||||
await dio.post<void>(
|
||||
endpoint('absence'),
|
||||
data: {
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'className': className,
|
||||
'absentFrom': isoDate(absentFrom),
|
||||
'absentUntil': isoDate(absentUntil),
|
||||
'phone': phone,
|
||||
'note': note,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -16,9 +16,23 @@ class CapabilitiesResponse {
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool pushNotifications;
|
||||
|
||||
/// How many days into the past/future the user may view the timetable.
|
||||
/// `null` (absent) means unlimited — the school year alone governs. The
|
||||
/// backend widens both to at least cover the current Mon–Sun week.
|
||||
final int? timetablePastDays;
|
||||
|
||||
final int? timetableFutureDays;
|
||||
|
||||
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
|
||||
/// predates the field or has no LDAP record for the user.
|
||||
final String? userType;
|
||||
|
||||
CapabilitiesResponse({
|
||||
required this.viewForeignTimetables,
|
||||
required this.pushNotifications,
|
||||
this.timetablePastDays,
|
||||
this.timetableFutureDays,
|
||||
this.userType,
|
||||
});
|
||||
|
||||
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -11,6 +11,9 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||
) => CapabilitiesResponse(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||
userType: json['userType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
@@ -18,4 +21,7 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
) => <String, dynamic>{
|
||||
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||
'pushNotifications': instance.pushNotifications,
|
||||
'timetablePastDays': instance.timetablePastDays,
|
||||
'timetableFutureDays': instance.timetableFutureDays,
|
||||
'userType': instance.userType,
|
||||
};
|
||||
|
||||
@@ -12,8 +12,9 @@ import 'telemetry_device_id.dart';
|
||||
|
||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||
/// upserts the stable install id, platform, app version and device info. Sent
|
||||
/// once on app start and again once push registration completes that session
|
||||
/// (so a fresh registration isn't under-reported until the next launch).
|
||||
/// on app start, again once push registration completes that session (so a
|
||||
/// fresh registration isn't under-reported until the next launch), and on
|
||||
/// resume after the app spent more than 15 minutes in the background.
|
||||
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||
/// mhsl.eu `server/userIndex/update` call.
|
||||
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||
@@ -21,7 +22,8 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||
|
||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
||||
/// send never disrupts app start. Used from the app shell's initState and
|
||||
/// re-emitted once push registration completes (see `_MainState._syncPush`).
|
||||
/// lifecycle handler, and re-emitted once push registration completes (see
|
||||
/// `_MainState._syncPush`).
|
||||
static void report({required bool notificationsEnabled}) {
|
||||
unawaited(
|
||||
TelemetryHeartbeat()
|
||||
|
||||
+35
-6
@@ -18,6 +18,7 @@ import 'routing/app_routes.dart';
|
||||
import 'share_intent/share_intent_listener.dart';
|
||||
import 'state/app/modules/app_modules.dart';
|
||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
@@ -46,9 +47,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
int _knownTotalTabs = 1;
|
||||
int _lastTabIndex = 0;
|
||||
bool _userOnLastTab = false;
|
||||
DateTime? _lastTelemetryAt;
|
||||
|
||||
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
||||
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
||||
static const Duration _telemetryInterval = Duration(minutes: 15);
|
||||
|
||||
void _onTabControllerChanged() {
|
||||
final newIndex = Main.bottomNavigator.index;
|
||||
@@ -60,7 +63,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
_syncChatListPolling();
|
||||
}
|
||||
|
||||
void _syncChatListPolling() {
|
||||
void _syncChatListPolling({bool refresh = true}) {
|
||||
if (!mounted) return;
|
||||
final modules = AppModule.getBottomBarModules(context);
|
||||
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
|
||||
@@ -70,19 +73,45 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
bloc.setAutoRefreshInterval(
|
||||
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
||||
);
|
||||
if (talkIsActive) bloc.refresh();
|
||||
if (talkIsActive && refresh) bloc.refresh();
|
||||
}
|
||||
|
||||
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
||||
// reliably while the app is suspended, so the window would still be open on
|
||||
// the resume it is supposed to let through.
|
||||
void _reportTelemetry() {
|
||||
if (!mounted) return;
|
||||
final now = DateTime.now();
|
||||
final last = _lastTelemetryAt;
|
||||
if (last != null && now.difference(last) < _telemetryInterval) return;
|
||||
_lastTelemetryAt = now;
|
||||
TelemetryHeartbeat.report(
|
||||
notificationsEnabled: context
|
||||
.read<SettingsCubit>()
|
||||
.val()
|
||||
.notificationSettings
|
||||
.enabled,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
log('AppLifecycle: $state');
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_reportTelemetry();
|
||||
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
|
||||
if (!mounted) return;
|
||||
log('Refreshing due to LifecycleChange');
|
||||
NotificationTasks.updateProviders(context);
|
||||
});
|
||||
// updateProviders already refreshes the chat list; only re-arm the poll.
|
||||
_syncChatListPolling(refresh: false);
|
||||
_handlePendingWidgetNavigation();
|
||||
} else if (mounted) {
|
||||
// Stop polling while backgrounded: a silent refresh failing in the
|
||||
// background would otherwise leave an error that flashes on the next
|
||||
// resume before the foreground refetch replaces it.
|
||||
context.read<ChatListBloc>().setAutoRefreshInterval(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +174,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
// Mirror BLoC updates into the home-screen widget without waiting
|
||||
// for the periodic background refresh.
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
_timetableWidgetSync?.cancel();
|
||||
_timetableWidgetSync = timetable.stream.listen((state) {
|
||||
final data = state.data;
|
||||
@@ -153,6 +183,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
WidgetPublisher.publishFromBlocState(
|
||||
data,
|
||||
settings: settingsCubit.val(),
|
||||
isTeacher: capabilitiesCubit.isTeacher,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -164,6 +195,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
WidgetPublisher.publishFromBlocState(
|
||||
initialData,
|
||||
settings: settingsCubit.val(),
|
||||
isTeacher: capabilitiesCubit.isTeacher,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -178,10 +210,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
TelemetryHeartbeat.report(
|
||||
notificationsEnabled:
|
||||
context.read<SettingsCubit>().val().notificationSettings.enabled,
|
||||
);
|
||||
_reportTelemetry();
|
||||
|
||||
// A refreshed FCM token invalidates the existing push subscription — the
|
||||
// NC device identifier stays stable, so we simply re-register (NC first,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
@@ -32,12 +33,20 @@ class WidgetBackgroundTask {
|
||||
|
||||
static const Duration periodicFrequency = Duration(minutes: 30);
|
||||
|
||||
/// A snapshot younger than this is considered fresh enough — a second
|
||||
/// trigger within the window (push + periodic slot coinciding) is skipped.
|
||||
static const Duration refreshDebounce = Duration(minutes: 10);
|
||||
|
||||
static Future<void> initialize() async {
|
||||
await Workmanager().initialize(_callbackDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
periodicTaskName,
|
||||
periodicTaskName,
|
||||
frequency: periodicFrequency,
|
||||
// iOS ignores `frequency:` and instead uses initialDelay as the
|
||||
// BGAppRefresh earliestBeginDate on every (auto-)resubmission —
|
||||
// without it each completed run is immediately eligible again.
|
||||
initialDelay: Platform.isIOS ? periodicFrequency : Duration.zero,
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
@@ -45,7 +54,21 @@ class WidgetBackgroundTask {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> requestImmediateRefresh() async {
|
||||
/// Single owner of the platform strategy for "refresh soon": Android
|
||||
/// enqueues a WorkManager one-off (retry + network constraint included),
|
||||
/// iOS runs inline — one-off Workmanager tasks there only execute
|
||||
/// in-process anyway, so the direct call is equivalent and skips the extra
|
||||
/// background engine. [inlineTimeout] bounds the inline path for callers
|
||||
/// with a hard budget (FCM handler).
|
||||
static Future<void> requestImmediateRefresh({
|
||||
bool force = true,
|
||||
Duration? inlineTimeout,
|
||||
}) async {
|
||||
if (Platform.isIOS) {
|
||||
final refresh = runRefreshNow(force: force);
|
||||
await (inlineTimeout == null ? refresh : refresh.timeout(inlineTimeout));
|
||||
return;
|
||||
}
|
||||
await Workmanager().registerOneOffTask(
|
||||
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
|
||||
oneOffTaskName,
|
||||
@@ -54,24 +77,61 @@ class WidgetBackgroundTask {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared refresh entry for the periodic worker, push triggers, and login.
|
||||
/// Throws on fetch failure so the worker path can signal a retry.
|
||||
static Future<void> runRefreshNow({bool force = false}) async {
|
||||
await WidgetSync.ensureInitialized();
|
||||
bool populated;
|
||||
try {
|
||||
// Bounded: a hanging keystore read must not stall the caller's budget
|
||||
// (FCM handler ~25s on iOS) forever.
|
||||
populated = await AccountData().waitForPopulation().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
} on TimeoutException {
|
||||
populated = false;
|
||||
}
|
||||
if (!populated) {
|
||||
// Deliberately does NOT flip the widget to logged-out: a failed or slow
|
||||
// keychain read (locked iOS device during the 06:00 silent push) is
|
||||
// indistinguishable from "never logged in" here, and blanking the
|
||||
// widget on a transient failure is worse than keeping the snapshot.
|
||||
// Logout/login manage the flag explicitly (WidgetSync.clear / login).
|
||||
log('[widget-refresh] credentials unavailable, skipping refresh');
|
||||
return;
|
||||
}
|
||||
final fetchedAt = await WidgetSync.getFetchedAt();
|
||||
if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) {
|
||||
log('[widget-refresh] snapshot is fresh, skipping refresh');
|
||||
return;
|
||||
}
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
static Future<void> cancelAll() async {
|
||||
await Workmanager().cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure debounce decision so it stays unit-testable. A `fetchedAt` in the
|
||||
/// future (clock change, debug time shift) never skips — refreshing is the
|
||||
/// safe direction.
|
||||
bool shouldSkipRefresh({
|
||||
required DateTime? fetchedAt,
|
||||
required DateTime now,
|
||||
required bool force,
|
||||
}) {
|
||||
if (force || fetchedAt == null) return false;
|
||||
final age = now.difference(fetchedAt);
|
||||
return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void _callbackDispatcher() {
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
try {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AccountData().waitForPopulation();
|
||||
if (!AccountData().isPopulated()) {
|
||||
log('[widget-bg] not logged in, skipping refresh');
|
||||
await WidgetSync.setLoggedIn(false);
|
||||
await WidgetSync.triggerUpdate();
|
||||
return true;
|
||||
}
|
||||
await _refresh();
|
||||
await WidgetBackgroundTask.runRefreshNow();
|
||||
return true;
|
||||
} on Exception catch (e, s) {
|
||||
log('[widget-bg] refresh failed: $e', stackTrace: s);
|
||||
@@ -94,39 +154,51 @@ Future<void> _refresh() async {
|
||||
}
|
||||
|
||||
final now = WidgetPublisher.widgetNow();
|
||||
// 14-day window so the week-widget rolls forward into next Monday's
|
||||
// lessons on Friday evening.
|
||||
final weekStart = _startOfWeek(now);
|
||||
final weekEndExclusive = weekStart.add(const Duration(days: 14));
|
||||
// Fetch window matches the week payload's window so the widget can roll
|
||||
// forward into next week's lessons without fresh data.
|
||||
final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
|
||||
final weekEndExclusive = weekStart.add(
|
||||
const Duration(days: WidgetDataMapper.weekWindowDays),
|
||||
);
|
||||
|
||||
final timetable = await TimetableGetWeek().run(
|
||||
// All six requests are independent — run them concurrently so the total
|
||||
// latency is the slowest request, not the sum (matters for the push path's
|
||||
// hard time budget). Reference-data failures fall through to null in the
|
||||
// mapper rather than aborting the whole refresh.
|
||||
final timetableFuture = TimetableGetWeek().run(
|
||||
from: weekStart,
|
||||
until: weekEndExclusive.subtract(const Duration(days: 1)),
|
||||
);
|
||||
|
||||
// Reference data — failures fall through to null in the mapper rather
|
||||
// than aborting the whole refresh.
|
||||
final subjects = await _runOrNull<TimetableGetSubjectsResponse>(
|
||||
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
|
||||
() => TimetableGetSubjects().run(),
|
||||
);
|
||||
final rooms = await _runOrNull<TimetableGetRoomsResponse>(
|
||||
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
|
||||
() => TimetableGetRooms().run(),
|
||||
);
|
||||
final holidays = await _runOrNull<TimetableGetHolidaysResponse>(
|
||||
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
|
||||
() => TimetableGetHolidays().run(),
|
||||
);
|
||||
final timegrid = await _runOrNull<TimetableGetTimegridResponse>(
|
||||
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
||||
() => TimetableGetTimegrid().run(),
|
||||
);
|
||||
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
|
||||
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
|
||||
() => GetCustomTimetableEvent(
|
||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||
).run(),
|
||||
);
|
||||
final timetable = await timetableFuture;
|
||||
final subjects = await subjectsFuture;
|
||||
final rooms = await roomsFuture;
|
||||
final holidays = await holidaysFuture;
|
||||
final timegrid = await timegridFuture;
|
||||
final customEvents = await customEventsFuture;
|
||||
|
||||
final lessons = timetable.entries;
|
||||
|
||||
final connectDouble = await WidgetSync.getConnectDoubleLessons();
|
||||
final [connectDouble, isTeacher] = await Future.wait([
|
||||
WidgetSync.getConnectDoubleLessons(),
|
||||
WidgetSync.getIsTeacher(),
|
||||
]);
|
||||
final dayData = WidgetDataMapper.buildDayData(
|
||||
now: now,
|
||||
lessons: lessons,
|
||||
@@ -136,6 +208,7 @@ Future<void> _refresh() async {
|
||||
timegrid: timegrid,
|
||||
customEvents: customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
final weekData = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -146,6 +219,7 @@ Future<void> _refresh() async {
|
||||
timegrid: timegrid,
|
||||
customEvents: customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
|
||||
await WidgetSync.writeDayData(dayData);
|
||||
@@ -158,11 +232,6 @@ Future<void> _refresh() async {
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _startOfWeek(DateTime reference) {
|
||||
final monday = reference.subtract(Duration(days: reference.weekday - 1));
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
|
||||
Future<T?> _runOrNull<T>(Future<T> Function() task) async {
|
||||
try {
|
||||
return await task();
|
||||
|
||||
@@ -17,6 +17,10 @@ class AccountData {
|
||||
// token, so two registrations need two app passwords.
|
||||
static const _appPasswordField = 'nextcloud_app_password';
|
||||
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
|
||||
// Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA):
|
||||
// the real password is not valid against Nextcloud, only the flow-issued
|
||||
// app password is — and no further app passwords can be minted silently.
|
||||
static const _loginFlowField = 'nextcloud_login_flow';
|
||||
// Persists the demo session across cold starts (see DemoMode).
|
||||
static const _demoField = 'is_demo';
|
||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
||||
@@ -38,10 +42,17 @@ class AccountData {
|
||||
String? _appPassword;
|
||||
String? _appPasswordTalk;
|
||||
bool _isDemo = false;
|
||||
bool _usesLoginFlow = false;
|
||||
|
||||
/// True while the active session is a local demo session (see DemoMode).
|
||||
bool get isDemo => _isDemo;
|
||||
|
||||
/// True when the Nextcloud credentials were obtained via Login Flow v2
|
||||
/// (browser login, e.g. because the account has two-factor authentication).
|
||||
/// In that mode the stored real password only authenticates MarianumConnect;
|
||||
/// every Nextcloud call must use the flow-issued app password.
|
||||
bool get usesLoginFlow => _usesLoginFlow;
|
||||
|
||||
String getUsername() {
|
||||
if (_username == null) throw Exception('Username not initialized');
|
||||
return _username!;
|
||||
@@ -86,9 +97,11 @@ class AccountData {
|
||||
_appPassword = null;
|
||||
_appPasswordTalk = null;
|
||||
_isDemo = false;
|
||||
_usesLoginFlow = false;
|
||||
await _secureStorage.delete(key: _usernameField);
|
||||
await _secureStorage.delete(key: _passwordField);
|
||||
await _secureStorage.delete(key: _demoField);
|
||||
await _secureStorage.delete(key: _loginFlowField);
|
||||
await _clearAppPasswordStorage();
|
||||
await _clearAppPasswordTalkStorage();
|
||||
}
|
||||
@@ -111,6 +124,17 @@ class AccountData {
|
||||
await _clearAppPasswordStorage();
|
||||
}
|
||||
|
||||
/// Adopts an app password obtained via Login Flow v2 and switches the
|
||||
/// account into flow mode (see [usesLoginFlow]). Any previously stored Talk
|
||||
/// app password belonged to the old session era and is dropped — the second
|
||||
/// (optional) flow pass stores a fresh one via [setAppPasswordTalk].
|
||||
Future<void> setLoginFlow(String appPassword) async {
|
||||
await setAppPassword(appPassword);
|
||||
await clearAppPasswordTalk();
|
||||
_usesLoginFlow = true;
|
||||
await _secureStorage.write(key: _loginFlowField, value: 'true');
|
||||
}
|
||||
|
||||
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
||||
|
||||
/// Persists the app password backing the Talk push registration.
|
||||
@@ -156,6 +180,7 @@ class AccountData {
|
||||
_username = await _secureStorage.read(key: _usernameField);
|
||||
_password = await _secureStorage.read(key: _passwordField);
|
||||
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
||||
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||
try {
|
||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||
_appPasswordTalk = await pushSecureStorage.read(
|
||||
@@ -209,6 +234,10 @@ class AccountData {
|
||||
String getTalkBasicAuthHeader() {
|
||||
_requirePopulated();
|
||||
if (!hasAppPasswordTalk()) {
|
||||
// Login-flow account whose second (talk) flow pass was skipped: no
|
||||
// silent minting possible, the talk registration shares the single
|
||||
// flow-issued credential.
|
||||
if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!);
|
||||
throw StateError('Talk app password not available yet');
|
||||
}
|
||||
return _basicAuth(_appPasswordTalk!);
|
||||
@@ -222,6 +251,15 @@ class AccountData {
|
||||
return _basicAuth(_password!);
|
||||
}
|
||||
|
||||
/// Secret authenticating against Nextcloud: the app password once available
|
||||
/// (minted or flow-issued), otherwise the real password. Mirrors the
|
||||
/// preference of [getBasicAuthHeader] for clients that need the raw secret
|
||||
/// (WebDAV client construction).
|
||||
String getNextcloudSecret() {
|
||||
_requirePopulated();
|
||||
return _appPassword ?? _password!;
|
||||
}
|
||||
|
||||
void _requirePopulated() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:crypton/crypton.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
@@ -12,6 +13,10 @@ import 'push_registration_store.dart';
|
||||
import 'push_renderer.dart';
|
||||
import 'push_subject.dart';
|
||||
|
||||
/// Wire value of the FCM `type` field for silent widget-refresh pushes.
|
||||
/// Mirrors PUSH_TYPE_WIDGET_REFRESH in MarianumConnect's MarMobileApiService.
|
||||
const String widgetRefreshPushType = 'widget-refresh';
|
||||
|
||||
/// How an incoming FCM payload should be interpreted.
|
||||
enum PushKind {
|
||||
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
|
||||
@@ -20,6 +25,11 @@ enum PushKind {
|
||||
/// Plaintext MarianumConnect direct push (`source == "connect"`).
|
||||
connect,
|
||||
|
||||
/// Silent MarianumConnect push requesting a home-widget data refresh
|
||||
/// (`source == "connect"` + `type == "widget-refresh"`). Never rendered,
|
||||
/// processed even with notifications off.
|
||||
widgetRefresh,
|
||||
|
||||
/// Neither — ignored.
|
||||
unknown,
|
||||
}
|
||||
@@ -31,7 +41,10 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
||||
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
|
||||
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
|
||||
if (hasSubject && hasSignature) return PushKind.nextcloud;
|
||||
if (data['source'] == 'connect') return PushKind.connect;
|
||||
if (data['source'] == 'connect') {
|
||||
if (data['type'] == widgetRefreshPushType) return PushKind.widgetRefresh;
|
||||
return PushKind.connect;
|
||||
}
|
||||
return PushKind.unknown;
|
||||
}
|
||||
|
||||
@@ -97,11 +110,30 @@ class PushMessageHandler {
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
break;
|
||||
case PushKind.widgetRefresh:
|
||||
// Deliberately before any notificationsEnabled gate: silent sync
|
||||
// pushes must work with notifications off.
|
||||
await _handleWidgetRefresh();
|
||||
break;
|
||||
case PushKind.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleWidgetRefresh() async {
|
||||
try {
|
||||
// The iOS FCM handler runs in the main isolate with a ~25s APNs
|
||||
// budget — bound the inline refresh below that so the completion
|
||||
// handler always fires in time.
|
||||
await WidgetBackgroundTask.requestImmediateRefresh(
|
||||
force: false,
|
||||
inlineTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
log('[push] widget refresh failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleConnect(
|
||||
RemoteMessage message, {
|
||||
required bool foreground,
|
||||
|
||||
@@ -67,6 +67,13 @@ class PushRegistration {
|
||||
/// registration binds to it, so it must be obtained before registering.
|
||||
Future<void> ensureAppPassword() async {
|
||||
if (AccountData().hasAppPassword()) return;
|
||||
if (AccountData().usesLoginFlow) {
|
||||
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
|
||||
// stilles Minting ist unmöglich. Reparatur nur interaktiv über
|
||||
// Einstellungen → „Nextcloud neu verbinden".
|
||||
log('Push: login-flow account without app password, cannot mint silently');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
@@ -79,6 +86,11 @@ class PushRegistration {
|
||||
/// (each `getapppassword` call with the real password mints a fresh one).
|
||||
Future<void> ensureTalkAppPassword() async {
|
||||
if (AccountData().hasAppPasswordTalk()) return;
|
||||
// Flow-Konten können still kein zweites App-Passwort münzen — das
|
||||
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
|
||||
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe
|
||||
// AccountData.getTalkBasicAuthHeader).
|
||||
if (AccountData().usesLoginFlow) return;
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPasswordTalk(appPassword);
|
||||
@@ -129,8 +141,19 @@ class PushRegistration {
|
||||
appVersion = null;
|
||||
}
|
||||
|
||||
final types = registrationTypesFor(
|
||||
usesLoginFlow: AccountData().usesLoginFlow,
|
||||
hasTalkAppPassword: AccountData().hasAppPasswordTalk(),
|
||||
);
|
||||
if (!types.contains(PushRegistrationType.general)) {
|
||||
await _recordAttempt(
|
||||
PushRegistrationType.general,
|
||||
'Ohne zweite Nextcloud-Freigabe nicht verfügbar (nur Talk-Push)',
|
||||
);
|
||||
}
|
||||
|
||||
var allOk = true;
|
||||
for (final type in PushRegistrationType.values) {
|
||||
for (final type in types) {
|
||||
final ok = await _registerType(
|
||||
type: type,
|
||||
fcmToken: fcmToken,
|
||||
@@ -260,6 +283,19 @@ class PushRegistration {
|
||||
await _store.clear();
|
||||
}
|
||||
|
||||
/// Pure decision which Nextcloud registrations this session can maintain.
|
||||
/// Flow-Konten (2FA), die nur den ersten Login-Flow-Durchlauf abgeschlossen
|
||||
/// haben, besitzen eine einzige NC-Session — Nextcloud bindet pro Session
|
||||
/// genau eine Subscription, also bleibt nur die (wichtigere)
|
||||
/// Talk-Registrierung. Mit dem zweiten (Talk-)App-Passwort aus dem
|
||||
/// optionalen zweiten Durchlauf laufen wieder beide.
|
||||
static List<PushRegistrationType> registrationTypesFor({
|
||||
required bool usesLoginFlow,
|
||||
required bool hasTalkAppPassword,
|
||||
}) => usesLoginFlow && !hasTalkAppPassword
|
||||
? const [PushRegistrationType.talk]
|
||||
: PushRegistrationType.values;
|
||||
|
||||
/// Pure decision for whether a persisted registration endpoint no longer
|
||||
/// matches the currently active one. A missing/empty stored value never
|
||||
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../state/app/modules/app_modules.dart';
|
||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
import '../view/login/nextcloud_login_flow_page.dart';
|
||||
import '../view/pages/files/files.dart';
|
||||
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
||||
import '../view/pages/foreign_timetable/element_picker_page.dart';
|
||||
@@ -114,6 +115,20 @@ class AppRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs the Nextcloud Login Flow v2 (browser login, e.g. for accounts with
|
||||
/// two-factor authentication) and resolves to `true` once an app password
|
||||
/// was adopted. Used from the login flow and the settings "reconnect"
|
||||
/// action.
|
||||
static Future<bool> openNextcloudLoginFlow(BuildContext context) async {
|
||||
final result = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => const NextcloudLoginFlowPage(),
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Opens the tappable, zoomable profile-picture viewer for [id].
|
||||
static void openLargeProfilePicture(BuildContext context, String id) {
|
||||
Navigator.of(context).push(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../storage/modules_settings.dart';
|
||||
import '../../../view/pages/absence_report/absence_report_view.dart';
|
||||
import '../../../view/pages/files/files.dart';
|
||||
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
||||
import '../../../view/pages/holidays/holidays_view.dart';
|
||||
@@ -137,6 +138,12 @@ class AppModule {
|
||||
breakerArea: BreakerArea.dates,
|
||||
create: MarianumDatesView.new,
|
||||
),
|
||||
Modules.absenceReport: AppModule(
|
||||
Modules.absenceReport,
|
||||
name: 'Krankmeldung',
|
||||
icon: () => Icon(Icons.sick_outlined),
|
||||
create: AbsenceReportView.new,
|
||||
),
|
||||
};
|
||||
|
||||
if (!showFiltered) {
|
||||
@@ -286,4 +293,5 @@ enum Modules {
|
||||
gradeAveragesCalculator,
|
||||
holidays,
|
||||
marianumDates,
|
||||
absenceReport,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
|
||||
bool get canReceivePushNotifications => state.pushNotifications;
|
||||
|
||||
int? get timetablePastDays => state.timetablePastDays;
|
||||
|
||||
int? get timetableFutureDays => state.timetableFutureDays;
|
||||
|
||||
/// Teacher accounts get the class shown on timetable tiles instead of their
|
||||
/// own name (see TimetableAppointmentFactory.showClassInsteadOfTeacher).
|
||||
bool get isTeacher => state.userType == 'TEACHER';
|
||||
|
||||
/// Refreshes capabilities from the server. On any failure (endpoint not yet
|
||||
/// live, network error, 4xx) the previously hydrated flags are kept but the
|
||||
/// state is marked `loaded` — a failed fetch never silently grants a
|
||||
@@ -32,6 +40,9 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
CapabilitiesState(
|
||||
viewForeignTimetables: response.viewForeignTimetables,
|
||||
pushNotifications: response.pushNotifications,
|
||||
timetablePastDays: response.timetablePastDays,
|
||||
timetableFutureDays: response.timetableFutureDays,
|
||||
userType: response.userType,
|
||||
loaded: true,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,12 @@ abstract class CapabilitiesState with _$CapabilitiesState {
|
||||
const factory CapabilitiesState({
|
||||
@Default(false) bool viewForeignTimetables,
|
||||
@Default(false) bool pushNotifications,
|
||||
// Days into the past/future the timetable may be scrolled. Null = no
|
||||
// client-side clamp; the (server-narrowed) school year alone governs.
|
||||
int? timetablePastDays,
|
||||
int? timetableFutureDays,
|
||||
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
|
||||
String? userType,
|
||||
// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
|
||||
@@ -15,7 +15,10 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$CapabilitiesState {
|
||||
|
||||
bool get viewForeignTimetables; bool get pushNotifications;// Whether a capability response (or a definitive failure) has been
|
||||
bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no
|
||||
// client-side clamp; the (server-narrowed) school year alone governs.
|
||||
int? get timetablePastDays; int? get timetableFutureDays;// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
|
||||
String? get userType;// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
bool get loaded;
|
||||
@@ -31,16 +34,16 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
|
||||
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool viewForeignTimetables, bool pushNotifications, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded
|
||||
});
|
||||
|
||||
|
||||
@@ -68,11 +71,14 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of CapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
@@ -158,10 +164,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _CapabilitiesState() when $default != null:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -179,10 +185,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _CapabilitiesState():
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -199,10 +205,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _CapabilitiesState() when $default != null:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -214,11 +220,17 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
|
||||
@JsonSerializable()
|
||||
|
||||
class _CapabilitiesState implements CapabilitiesState {
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.loaded = false});
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false});
|
||||
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
|
||||
|
||||
@override@JsonKey() final bool viewForeignTimetables;
|
||||
@override@JsonKey() final bool pushNotifications;
|
||||
// Days into the past/future the timetable may be scrolled. Null = no
|
||||
// client-side clamp; the (server-narrowed) school year alone governs.
|
||||
@override final int? timetablePastDays;
|
||||
@override final int? timetableFutureDays;
|
||||
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
|
||||
@override final String? userType;
|
||||
// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
@@ -237,16 +249,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +269,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
|
||||
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool viewForeignTimetables, bool pushNotifications, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded
|
||||
});
|
||||
|
||||
|
||||
@@ -274,11 +286,14 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of CapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) {
|
||||
return _then(_CapabilitiesState(
|
||||
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
|
||||
_CapabilitiesState(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||
userType: json['userType'] as String?,
|
||||
loaded: json['loaded'] as bool? ?? false,
|
||||
);
|
||||
|
||||
@@ -17,5 +20,8 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
|
||||
<String, dynamic>{
|
||||
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||
'pushNotifications': instance.pushNotifications,
|
||||
'timetablePastDays': instance.timetablePastDays,
|
||||
'timetableFutureDays': instance.timetableFutureDays,
|
||||
'userType': instance.userType,
|
||||
'loaded': instance.loaded,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_history.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
|
||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
@@ -56,7 +57,22 @@ class ChatBloc
|
||||
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toStorage(ChatState state) => state.toJson();
|
||||
Map<String, dynamic>? toStorage(ChatState state) {
|
||||
final response = state.chatResponse;
|
||||
if (response == null ||
|
||||
response.data.length <= _kMaxPersistedMessages) {
|
||||
return state.toJson();
|
||||
}
|
||||
// Keep only the newest N; trimming drops older messages, so there is
|
||||
// definitely more history to page back in after a restart.
|
||||
final newest = response
|
||||
.sortByTimestamp()
|
||||
.reversed
|
||||
.take(_kMaxPersistedMessages)
|
||||
.toSet();
|
||||
final trimmed = GetChatResponse(newest)..headers = response.headers;
|
||||
return state.copyWith(chatResponse: trimmed, hasMoreOld: true).toJson();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> gatherData() async {
|
||||
@@ -75,7 +91,16 @@ class ChatBloc
|
||||
return;
|
||||
}
|
||||
_stopLongPoll();
|
||||
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
|
||||
add(
|
||||
Emit(
|
||||
(s) => s.copyWith(
|
||||
currentToken: token,
|
||||
chatResponse: null,
|
||||
hasMoreOld: true,
|
||||
isLoadingOlder: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
add(RefetchStarted<ChatState>());
|
||||
_scheduleLoad(token);
|
||||
}
|
||||
@@ -100,6 +125,40 @@ class ChatBloc
|
||||
_stopLongPoll();
|
||||
}
|
||||
|
||||
/// Pages in the next block of messages older than the oldest currently held.
|
||||
/// No-ops when nothing more can be loaded or a load is already in flight.
|
||||
Future<void> loadOlder() async {
|
||||
final state = innerState;
|
||||
if (state == null) return;
|
||||
final token = state.currentToken;
|
||||
if (token.isEmpty) return;
|
||||
if (state.isLoadingOlder || !state.hasMoreOld) return;
|
||||
final response = state.chatResponse;
|
||||
if (response == null) return;
|
||||
final oldestId = _minMessageId(response);
|
||||
if (oldestId <= 0) return;
|
||||
|
||||
add(Emit((s) => s.copyWith(isLoadingOlder: true)));
|
||||
try {
|
||||
final older = await GetChatHistory(
|
||||
chatToken: token,
|
||||
lastKnownMessageId: oldestId,
|
||||
limit: _kOlderPageSize,
|
||||
).run();
|
||||
if (isClosed) return;
|
||||
if ((innerState?.currentToken ?? '') != token) return;
|
||||
if (older == null || older.data.isEmpty) {
|
||||
add(Emit((s) => s.copyWith(hasMoreOld: false, isLoadingOlder: false)));
|
||||
return;
|
||||
}
|
||||
_applyOlderResponse(older);
|
||||
} on Object catch (e) {
|
||||
log('Load older messages for $token failed: $e');
|
||||
if (isClosed) return;
|
||||
add(Emit((s) => s.copyWith(isLoadingOlder: false)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendServerReadMarker(String token, int messageId) async {
|
||||
try {
|
||||
await SetReadMarker(
|
||||
@@ -245,7 +304,15 @@ class ChatBloc
|
||||
void _applyChatResponse(GetChatResponse incoming) {
|
||||
final current = innerState?.chatResponse;
|
||||
if (current == null) {
|
||||
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
|
||||
// Initial load: a short first page means there is nothing older to page in.
|
||||
add(
|
||||
DataGathered(
|
||||
(s) => s.copyWith(
|
||||
chatResponse: incoming,
|
||||
hasMoreOld: incoming.data.length >= _kInitialPageSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final byId = <int, GetChatResponseObject>{};
|
||||
@@ -260,6 +327,32 @@ class ChatBloc
|
||||
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
|
||||
}
|
||||
|
||||
/// Merges an older history page. Unlike [_applyChatResponse] it keeps the
|
||||
/// current headers — the older page's `x-chat-last-common-read` would regress
|
||||
/// the read-status shown for already-visible messages.
|
||||
void _applyOlderResponse(GetChatResponse older) {
|
||||
final current = innerState?.chatResponse;
|
||||
if (current == null) return;
|
||||
final byId = <int, GetChatResponseObject>{};
|
||||
for (final m in current.data) {
|
||||
byId[m.id] = m;
|
||||
}
|
||||
for (final m in older.data) {
|
||||
byId.putIfAbsent(m.id, () => m);
|
||||
}
|
||||
final merged = GetChatResponse(byId.values.toSet())
|
||||
..headers = current.headers;
|
||||
add(
|
||||
DataGathered(
|
||||
(s) => s.copyWith(
|
||||
chatResponse: merged,
|
||||
hasMoreOld: older.data.length >= _kOlderPageSize,
|
||||
isLoadingOlder: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _maxMessageId(GetChatResponse? response) {
|
||||
if (response == null) return 0;
|
||||
var max = 0;
|
||||
@@ -269,6 +362,16 @@ class ChatBloc
|
||||
return max;
|
||||
}
|
||||
|
||||
int _minMessageId(GetChatResponse? response) {
|
||||
if (response == null) return 0;
|
||||
var min = 0;
|
||||
for (final m in response.data) {
|
||||
if (m.id <= 0) continue; // skip dummies
|
||||
if (min == 0 || m.id < min) min = m.id;
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
|
||||
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
|
||||
GetChatResponseObject? best;
|
||||
@@ -288,3 +391,16 @@ class ChatBloc
|
||||
}
|
||||
|
||||
const _kLongPollLastGivenHeader = 'x-chat-last-given';
|
||||
|
||||
/// Small first page shown on open (keeps the initial request cheap). Must match
|
||||
/// the `limit` in GetChatCache (get_chat_cache.dart).
|
||||
const _kInitialPageSize = 50;
|
||||
|
||||
/// Upper bound on how many messages are persisted via HydratedBloc. The full
|
||||
/// scrolled history stays in memory at runtime; only the newest this-many
|
||||
/// survive a restart (older ones are re-fetchable via scroll-up).
|
||||
const _kMaxPersistedMessages = 500;
|
||||
|
||||
/// Larger block fetched per scroll-up so paging back through history needs
|
||||
/// fewer round trips.
|
||||
const _kOlderPageSize = 200;
|
||||
|
||||
@@ -11,6 +11,8 @@ abstract class ChatState with _$ChatState {
|
||||
@Default('') String currentToken,
|
||||
GetChatResponse? chatResponse,
|
||||
int? referenceMessageId,
|
||||
@Default(true) bool hasMoreOld,
|
||||
@Default(false) bool isLoadingOlder,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.fromJson(Map<String, Object?> json) =>
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatState {
|
||||
|
||||
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId;
|
||||
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; bool get hasMoreOld; bool get isLoadingOlder;
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -28,16 +28,16 @@ $ChatStateCopyWith<ChatState> get copyWith => _$ChatStateCopyWithImpl<ChatState>
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $ChatStateCopyWith<$Res> {
|
||||
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
|
||||
});
|
||||
|
||||
|
||||
@@ -65,12 +65,14 @@ class _$ChatStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
||||
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -155,10 +157,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatState() when $default != null:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -176,10 +178,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatState():
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -196,10 +198,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatState() when $default != null:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
|
||||
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -211,12 +213,14 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
|
||||
@JsonSerializable()
|
||||
|
||||
class _ChatState implements ChatState {
|
||||
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId});
|
||||
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId, this.hasMoreOld = true, this.isLoadingOlder = false});
|
||||
factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json);
|
||||
|
||||
@override@JsonKey() final String currentToken;
|
||||
@override final GetChatResponse? chatResponse;
|
||||
@override final int? referenceMessageId;
|
||||
@override@JsonKey() final bool hasMoreOld;
|
||||
@override@JsonKey() final bool isLoadingOlder;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -231,16 +235,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
|
||||
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
|
||||
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +255,7 @@ abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Re
|
||||
factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
|
||||
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
|
||||
});
|
||||
|
||||
|
||||
@@ -268,12 +272,14 @@ class __$ChatStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
|
||||
return _then(_ChatState(
|
||||
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
|
||||
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
|
||||
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
|
||||
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ _ChatState _$ChatStateFromJson(Map<String, dynamic> json) => _ChatState(
|
||||
? null
|
||||
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
|
||||
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
|
||||
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
|
||||
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
||||
@@ -19,4 +21,6 @@ Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
|
||||
'currentToken': instance.currentToken,
|
||||
'chatResponse': instance.chatResponse,
|
||||
'referenceMessageId': instance.referenceMessageId,
|
||||
'hasMoreOld': instance.hasMoreOld,
|
||||
'isLoadingOlder': instance.isLoadingOlder,
|
||||
};
|
||||
|
||||
@@ -39,4 +39,5 @@ const _$ModulesEnumMap = {
|
||||
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
|
||||
Modules.holidays: 'holidays',
|
||||
Modules.marianumDates: 'marianumDates',
|
||||
Modules.absenceReport: 'absenceReport',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -6,13 +5,29 @@ import 'package:flutter/foundation.dart';
|
||||
import '../../api/demo/demo_mode.dart';
|
||||
import '../../api/errors/auth_exception.dart';
|
||||
import '../../api/errors/error_mapper.dart';
|
||||
import '../../api/marianumcloud/app_password/get_app_password.dart';
|
||||
import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../push/push_registration.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
/// Outcome of a login attempt.
|
||||
enum LoginResult {
|
||||
/// Fully logged in — the view transitions to `loggedIn`.
|
||||
success,
|
||||
|
||||
/// Credentials rejected or a transport problem; the error is exposed via
|
||||
/// [LoginController.errorMessage].
|
||||
failure,
|
||||
|
||||
/// MarianumConnect accepted the credentials, but Nextcloud rejects them
|
||||
/// (two-factor authentication active or diverging password). The view must
|
||||
/// complete the Nextcloud Login Flow v2 in the browser before proceeding.
|
||||
nextcloudLoginRequired,
|
||||
}
|
||||
|
||||
/// Owns the login flow's transient state (loading, last error) so it can be
|
||||
/// driven from a thin Stateful view and unit-tested without a widget tree.
|
||||
class LoginController extends ChangeNotifier {
|
||||
@@ -24,10 +39,8 @@ class LoginController extends ChangeNotifier {
|
||||
String? get errorMessage => _errorMessage;
|
||||
String? get errorDetails => _errorDetails;
|
||||
|
||||
/// Returns `true` when the credential probe succeeded. The view should
|
||||
/// then transition the AccountBloc to `loggedIn`.
|
||||
Future<bool> submit(String username, String password) async {
|
||||
if (_loading) return false;
|
||||
Future<LoginResult> submit(String username, String password) async {
|
||||
if (_loading) return LoginResult.failure;
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
_errorDetails = null;
|
||||
@@ -45,7 +58,7 @@ class LoginController extends ChangeNotifier {
|
||||
await AccountData().setDemo(user);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
return LoginResult.success;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -65,13 +78,13 @@ class LoginController extends ChangeNotifier {
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
await AccountData().setData(user, password);
|
||||
// Mint the Nextcloud app password now so it's ready for the push
|
||||
// registration and subsequent NC calls. Non-blocking: on failure push
|
||||
// stays off and retries on the next start.
|
||||
unawaited(PushRegistration().ensureAppPassword());
|
||||
// Mint the Nextcloud app password now — it doubles as the Nextcloud
|
||||
// credential probe: a rejection means 2FA is active (or the NC password
|
||||
// diverges) and the login must finish interactively in the browser.
|
||||
final ncReady = await _prepareNextcloudAppPassword();
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
await AccountData().removeData();
|
||||
@@ -83,7 +96,41 @@ class LoginController extends ChangeNotifier {
|
||||
_errorDetails = errorToTechnicalDetails(e);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return LoginResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to mint the Nextcloud app password with the just-verified password.
|
||||
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
|
||||
/// Transport/server problems stay non-blocking (like the previous
|
||||
/// fire-and-forget mint): the mint retries with the push registration.
|
||||
Future<bool> _prepareNextcloudAppPassword() async {
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
return true;
|
||||
} on AuthException {
|
||||
return false;
|
||||
} on Object catch (e) {
|
||||
log('Nextcloud app password mint failed (non-blocking): $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolls the half-finished login back after the user cancelled the
|
||||
/// Nextcloud browser login: revoke the fresh MarianumConnect token and wipe
|
||||
/// the stored credentials, then surface why the login did not complete.
|
||||
Future<void> abortNextcloudLogin() async {
|
||||
try {
|
||||
await AuthLogout().run();
|
||||
} on Object catch (e) {
|
||||
log('Login rollback: MC logout failed: $e');
|
||||
}
|
||||
await AccountData().removeData();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
_errorMessage =
|
||||
'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.';
|
||||
_errorDetails = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/errors/error_mapper.dart';
|
||||
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
||||
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../widget/app_progress_indicator.dart';
|
||||
|
||||
/// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine
|
||||
/// App-Passwort (voller Browser-Login inkl. 2FA), der zweite das
|
||||
/// Talk-App-Passwort für die zweite Push-Subscription — der Browser hat dann
|
||||
/// bereits eine Session, es bleibt nur der „Zugriff gewähren"-Tipp.
|
||||
enum _FlowStep { primary, talk }
|
||||
|
||||
/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the
|
||||
/// user confirmed it there (2FA happens inside the browser) and adopts the
|
||||
/// returned app password via [AccountData.setLoginFlow]. A second, skippable
|
||||
/// pass mints the Talk app password so flow accounts keep BOTH push
|
||||
/// subscriptions (see PushRegistrationType). Pops `true` once the primary
|
||||
/// credential was adopted, `false`/`null` when the user backs out before that.
|
||||
class NextcloudLoginFlowPage extends StatefulWidget {
|
||||
const NextcloudLoginFlowPage({super.key});
|
||||
|
||||
@override
|
||||
State<NextcloudLoginFlowPage> createState() => _NextcloudLoginFlowPageState();
|
||||
}
|
||||
|
||||
class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
with WidgetsBindingObserver {
|
||||
static const _pollInterval = Duration(seconds: 3);
|
||||
// Serverseitig verfällt der Flow-Token nach 20 Minuten — danach würde der
|
||||
// Poll für immer 404 liefern, also vorher mit klarer Meldung abbrechen.
|
||||
static const _flowTimeout = Duration(minutes: 15);
|
||||
|
||||
final LoginFlowApi _api = LoginFlowApi();
|
||||
_FlowStep _step = _FlowStep.primary;
|
||||
LoginFlowInit? _flow;
|
||||
Timer? _timer;
|
||||
DateTime? _startedAt;
|
||||
bool _polling = false;
|
||||
bool _finished = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
unawaited(_start());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Der Nutzer kommt gerade aus dem Browser zurück — sofort pollen statt
|
||||
// bis zu einem Intervall zu warten.
|
||||
if (state == AppLifecycleState.resumed) unawaited(_poll());
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_error = null;
|
||||
_flow = null;
|
||||
});
|
||||
try {
|
||||
final flow = await _api.start();
|
||||
if (!mounted) return;
|
||||
setState(() => _flow = flow);
|
||||
_startedAt = DateTime.now();
|
||||
_timer = Timer.periodic(_pollInterval, (_) => _poll());
|
||||
unawaited(AppRoutes.openExternalUrl(flow.loginUrl));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = errorToUserMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
final flow = _flow;
|
||||
if (flow == null || _polling || _finished || _error != null) return;
|
||||
final startedAt = _startedAt;
|
||||
if (startedAt != null &&
|
||||
DateTime.now().difference(startedAt) > _flowTimeout) {
|
||||
_timer?.cancel();
|
||||
setState(
|
||||
() => _error =
|
||||
'Zeitüberschreitung — die Anmeldung im Browser wurde nicht abgeschlossen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_polling = true;
|
||||
try {
|
||||
final credentials = await _api.poll(flow);
|
||||
if (credentials == null || _finished || !mounted) return;
|
||||
if (!LoginFlowApi.loginNameMatches(
|
||||
expected: AccountData().getUsername(),
|
||||
actual: credentials.loginName,
|
||||
)) {
|
||||
_timer?.cancel();
|
||||
// Das versehentlich für das fremde Konto ausgestellte App-Passwort
|
||||
// nicht liegen lassen.
|
||||
unawaited(_revokeForeignAppPassword(credentials));
|
||||
setState(
|
||||
() => _error =
|
||||
'Im Browser wurde ein anderes Konto angemeldet („${credentials.loginName}“). '
|
||||
'Bitte versuche es erneut mit deinem Konto.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (_step) {
|
||||
case _FlowStep.primary:
|
||||
await AccountData().setLoginFlow(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht
|
||||
// bereits, es fehlt nur noch der Grant-Tipp.
|
||||
setState(() => _step = _FlowStep.talk);
|
||||
unawaited(_start());
|
||||
case _FlowStep.talk:
|
||||
_finished = true;
|
||||
_timer?.cancel();
|
||||
await AccountData().setAppPasswordTalk(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
// Transienter Poll-Fehler (z.B. kurz offline) — der nächste Tick
|
||||
// versucht es erneut.
|
||||
log('Login flow poll failed (retrying): $e');
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Talk-Schritt ist optional: ohne zweites App-Passwort funktioniert
|
||||
/// alles außer den allgemeinen Nextcloud-Pushes (Talk-Push bleibt erhalten).
|
||||
void _skipTalkStep() {
|
||||
_finished = true;
|
||||
_timer?.cancel();
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
static Future<void> _revokeForeignAppPassword(
|
||||
LoginFlowCredentials credentials,
|
||||
) async {
|
||||
try {
|
||||
final basic = base64Encode(
|
||||
utf8.encode('${credentials.loginName}:${credentials.appPassword}'),
|
||||
);
|
||||
await DeleteAppPassword().run(authorizationHeader: 'Basic $basic');
|
||||
} on Object catch (e) {
|
||||
log('Login flow: could not revoke foreign app password: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final flow = _flow;
|
||||
final error = _error;
|
||||
final isTalkStep = _step == _FlowStep.talk;
|
||||
// Ab dem Talk-Schritt ist das primäre App-Passwort bereits übernommen —
|
||||
// Zurück heißt dann „überspringen" (pop true), nicht „Login abbrechen":
|
||||
// die Aufrufer würden bei false den kompletten Login zurückrollen.
|
||||
return PopScope(
|
||||
canPop: !isTalkStep,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && !_finished) _skipTalkStep();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Nextcloud-Anmeldung')),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
isTalkStep
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.verified_user_outlined,
|
||||
size: 56,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isTalkStep
|
||||
? 'Fast geschafft!'
|
||||
: 'Bestätigung erforderlich',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isTalkStep
|
||||
? 'Damit Benachrichtigungen vollständig ankommen, braucht '
|
||||
'die App eine zweite Freigabe. Du bist im Browser '
|
||||
'bereits angemeldet — es genügt ein Tipp auf '
|
||||
'„Zugriff gewähren“.'
|
||||
: 'Dein Konto ist zusätzlich geschützt (z.B. durch '
|
||||
'Zwei-Faktor-Authentifizierung). Schließe die '
|
||||
'Anmeldung im Browser ab — danach geht es hier '
|
||||
'automatisch weiter.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (error != null) ...[
|
||||
Text(
|
||||
error,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _start,
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
] else if (flow == null) ...[
|
||||
const Center(child: AppProgressIndicator.medium()),
|
||||
] else ...[
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
onPressed: () =>
|
||||
unawaited(AppRoutes.openExternalUrl(flow.loginUrl)),
|
||||
label: Text(
|
||||
isTalkStep
|
||||
? 'Freigabe im Browser bestätigen'
|
||||
: 'Anmeldung im Browser öffnen',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const AppProgressIndicator.small(),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Warte auf Bestätigung im Browser…',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (isTalkStep) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: _skipTalkStep,
|
||||
child: const Text('Überspringen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../login_controller.dart';
|
||||
import 'login_error_banner.dart';
|
||||
|
||||
@@ -51,11 +52,27 @@ class _LoginCardState extends State<LoginCard> {
|
||||
Future<void> _submit() async {
|
||||
if (widget.controller.loading) return;
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final ok = await widget.controller.submit(
|
||||
final result = await widget.controller.submit(
|
||||
_usernameController.text,
|
||||
_passwordController.text,
|
||||
);
|
||||
if (ok && mounted) widget.onSuccess();
|
||||
if (!mounted) return;
|
||||
switch (result) {
|
||||
case LoginResult.success:
|
||||
widget.onSuccess();
|
||||
case LoginResult.nextcloudLoginRequired:
|
||||
// 2FA (oder abweichendes NC-Passwort): Anmeldung im Browser über den
|
||||
// Login Flow v2 abschließen; ohne Erfolg wird der Login zurückgerollt.
|
||||
final ok = await AppRoutes.openNextcloudLoginFlow(context);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
widget.onSuccess();
|
||||
} else {
|
||||
await widget.controller.abortNextcloudLogin();
|
||||
}
|
||||
case LoginResult.failure:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration _decoration(ThemeData theme, String label, IconData icon) =>
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../api/errors/error_mapper.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_classes.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_prefill.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||
import '../../../api/marianumconnect/queries/absence/absence_submit.dart';
|
||||
import '../../../extensions/date_time.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/async_action_button.dart';
|
||||
import '../../../widget/demo_restricted.dart';
|
||||
import '../../../widget/focus_behaviour.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
|
||||
/// Mobile mirror of the public absence-report form: submit-only (no history —
|
||||
/// that lives on the web). Identity/class/phone are prefilled from the backend
|
||||
/// but stay editable; the class list matches the submit validation source.
|
||||
/// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`).
|
||||
class AbsenceReportView extends StatefulWidget {
|
||||
const AbsenceReportView({super.key});
|
||||
|
||||
@override
|
||||
State<AbsenceReportView> createState() => _AbsenceReportViewState();
|
||||
}
|
||||
|
||||
class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
static const String _required = 'Dieses Feld ist erforderlich.';
|
||||
|
||||
final TextEditingController _firstName = TextEditingController();
|
||||
final TextEditingController _lastName = TextEditingController();
|
||||
final TextEditingController _phone = TextEditingController();
|
||||
final TextEditingController _note = TextEditingController();
|
||||
final AsyncActionController _submitController = AsyncActionController();
|
||||
|
||||
late Future<void> _init;
|
||||
List<String> _classes = const [];
|
||||
String? _selectedClass;
|
||||
late DateTime _absentFrom;
|
||||
late DateTime _absentUntil;
|
||||
bool _submitted = false;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final today = DateUtils.dateOnly(DateTime.now());
|
||||
_absentFrom = today;
|
||||
_absentUntil = today;
|
||||
for (final c in [_firstName, _lastName, _phone, _note]) {
|
||||
c.addListener(_onFieldChanged);
|
||||
}
|
||||
_init = _load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_firstName, _lastName, _phone, _note]) {
|
||||
c.dispose();
|
||||
}
|
||||
_submitController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Once a submit surfaced errors, re-render on every keystroke so the inline
|
||||
// field errors clear as soon as the offending field is filled.
|
||||
void _onFieldChanged() {
|
||||
if (_submitted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
// Both GETs are independent — fire them together. Prefill is best-effort
|
||||
// (mapped to null on failure), so a classes error still propagates while a
|
||||
// prefill failure never surfaces as an unhandled async error.
|
||||
final classesFuture = AbsenceClasses().run();
|
||||
final prefillFuture = AbsencePrefill().run().then<AbsencePrefillResponse?>(
|
||||
(p) => p,
|
||||
onError: (_) => null,
|
||||
);
|
||||
final classes = await classesFuture;
|
||||
final prefill = await prefillFuture;
|
||||
if (prefill != null) _applyPrefill(prefill, classes);
|
||||
_classes = classes;
|
||||
}
|
||||
|
||||
void _applyPrefill(AbsencePrefillResponse p, List<String> classes) {
|
||||
if (_firstName.text.isEmpty) _firstName.text = p.firstName;
|
||||
if (_lastName.text.isEmpty) _lastName.text = p.lastName;
|
||||
if (_phone.text.isEmpty) _phone.text = p.phone;
|
||||
if (_selectedClass == null && classes.contains(p.className)) {
|
||||
_selectedClass = p.className;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _startInPast =>
|
||||
_absentFrom.isBefore(DateUtils.dateOnly(DateTime.now()));
|
||||
|
||||
bool get _endBeforeStart => _absentUntil.isBefore(_absentFrom);
|
||||
|
||||
Future<void> _pickFrom() async {
|
||||
final today = DateUtils.dateOnly(DateTime.now());
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _absentFrom.isBefore(today) ? today : _absentFrom,
|
||||
firstDate: today,
|
||||
lastDate: DateTime(today.year + 1, today.month, today.day),
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() {
|
||||
_absentFrom = picked;
|
||||
if (_endBeforeStart) _absentUntil = _absentFrom;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickUntil() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _endBeforeStart ? _absentFrom : _absentUntil,
|
||||
firstDate: _absentFrom,
|
||||
lastDate: DateTime(
|
||||
_absentFrom.year + 1,
|
||||
_absentFrom.month,
|
||||
_absentFrom.day,
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
setState(() => _absentUntil = picked);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (guardDemoAction(context)) return;
|
||||
final valid =
|
||||
_firstName.text.trim().isNotEmpty &&
|
||||
_lastName.text.trim().isNotEmpty &&
|
||||
_selectedClass != null &&
|
||||
_phone.text.trim().isNotEmpty &&
|
||||
_note.text.trim().isNotEmpty &&
|
||||
!_startInPast &&
|
||||
!_endBeforeStart;
|
||||
if (!valid) {
|
||||
setState(() => _submitted = true);
|
||||
return;
|
||||
}
|
||||
await AbsenceSubmit().run(
|
||||
firstName: _firstName.text.trim(),
|
||||
lastName: _lastName.text.trim(),
|
||||
className: _selectedClass!,
|
||||
absentFrom: _absentFrom,
|
||||
absentUntil: _absentUntil,
|
||||
phone: _phone.text.trim(),
|
||||
note: _note.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
// Replace the whole form with a terminal success screen. There is
|
||||
// deliberately no way back to the form here — to file another report the
|
||||
// user leaves the module and re-enters (which builds a fresh form).
|
||||
setState(() => _done = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Krankmeldung')),
|
||||
body: _done ? const _SubmittedView() : _buildBody(context),
|
||||
);
|
||||
|
||||
Widget _buildBody(BuildContext context) => FutureBuilder<void>(
|
||||
future: _init,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return PlaceholderView(
|
||||
icon: Icons.error_outline,
|
||||
text: errorToUserMessage(snapshot.error),
|
||||
button: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
final reload = _load();
|
||||
setState(() {
|
||||
_init = reload;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildForm(context);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _buildForm(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Bitte tragen Sie hier die voraussichtliche Abwesenheit ein. '
|
||||
'Bitte denken Sie auch an die schriftliche Entschuldigung.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _firstName,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: _decoration(
|
||||
'Vorname',
|
||||
error: _requiredError(_firstName),
|
||||
),
|
||||
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _lastName,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: _decoration(
|
||||
'Nachname',
|
||||
error: _requiredError(_lastName),
|
||||
),
|
||||
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _selectedClass,
|
||||
isExpanded: true,
|
||||
hint: const Text('— bitte wählen —'),
|
||||
decoration: _decoration(
|
||||
'Klasse',
|
||||
error: _submitted && _selectedClass == null ? _required : null,
|
||||
),
|
||||
items: _classes
|
||||
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _selectedClass = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DateField(
|
||||
label: 'Fehlt ab',
|
||||
value: _absentFrom.formatDate(),
|
||||
error: _submitted && _startInPast
|
||||
? 'Das Startdatum darf nicht in der Vergangenheit liegen.'
|
||||
: null,
|
||||
onTap: _pickFrom,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DateField(
|
||||
label: 'bis',
|
||||
value: _absentUntil.formatDate(),
|
||||
error: _submitted && _endBeforeStart
|
||||
? 'Das Enddatum darf nicht vor dem Startdatum liegen.'
|
||||
: null,
|
||||
onTap: _pickUntil,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: _decoration(
|
||||
'Telefonnummer für Rückfragen',
|
||||
error: _requiredError(_phone),
|
||||
),
|
||||
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _note,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: _decoration(
|
||||
'Bemerkung / Grund',
|
||||
error: _requiredError(_note),
|
||||
),
|
||||
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AsyncActionButton(
|
||||
controller: _submitController,
|
||||
onPressed: _submit,
|
||||
child: const Text('Abwesenheit melden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _requiredError(TextEditingController controller) =>
|
||||
_submitted && controller.text.trim().isEmpty ? _required : null;
|
||||
|
||||
InputDecoration _decoration(String label, {String? error, String? hint}) =>
|
||||
InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Terminal success screen shown in place of the form after a report was
|
||||
/// submitted. Mirrors the public web form's done page and intentionally offers
|
||||
/// no path back to the form — filing another report means re-entering the
|
||||
/// module.
|
||||
class _SubmittedView extends StatelessWidget {
|
||||
const _SubmittedView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 60,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Ihre Abwesenheit wurde erfolgreich übermittelt!',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Vielen Dank und bei Krankheit gute Besserung!',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Bei Fragen wenden Sie sich bitte an unser Sekretariat. '
|
||||
'Tel: 0661-969120',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateField extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final String? error;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DateField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.error,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => InkWell(
|
||||
onTap: onTap,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: label,
|
||||
errorText: error,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.date_range_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/confirm_dialog.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import '../../../widget/route_transition_gate.dart';
|
||||
|
||||
class MessageView extends StatefulWidget {
|
||||
final String id;
|
||||
@@ -35,11 +36,13 @@ class _MessageViewState extends State<MessageView> {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
return SfPdfViewer.memory(
|
||||
return RouteTransitionGate(
|
||||
builder: (context) => SfPdfViewer.memory(
|
||||
snapshot.data!,
|
||||
enableHyperlinkNavigation: true,
|
||||
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
|
||||
ConfirmDialog.openBrowser(context, e.uri),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -167,10 +169,35 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
|
||||
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
||||
// Flow-Mechanismus nichts mitbekommen.
|
||||
if (!AccountData().isDemo && AccountData().usesLoginFlow)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.cloud_sync_outlined),
|
||||
title: const Text('Nextcloud neu verbinden'),
|
||||
subtitle: const Text(
|
||||
'Bei Anmeldeproblemen in Talk oder Dateien',
|
||||
),
|
||||
closeOnSuccess: false,
|
||||
onPressed: _reconnectNextcloud,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Erneuert die Nextcloud-Zugangsdaten über den Login Flow v2 (inkl. des
|
||||
/// zweiten Talk-Durchlaufs) und bindet die Push-Subscription neu.
|
||||
Future<void> _reconnectNextcloud() async {
|
||||
final ok = await AppRoutes.openNextcloudLoginFlow(context);
|
||||
if (!ok || !mounted) return;
|
||||
// Neues App-Passwort = neue NC-Session: die Push-Subscription neu binden.
|
||||
unawaited(PushRegistration().register());
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
||||
|
||||
@@ -42,9 +42,15 @@ class ChatView extends StatefulWidget {
|
||||
|
||||
class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _positionsListener =
|
||||
ItemPositionsListener.create();
|
||||
final TextEditingController _searchTextController = TextEditingController();
|
||||
final Map<int, int> _matchIndices = {};
|
||||
|
||||
// Number of rows currently rendered; kept in sync in build so the scroll
|
||||
// listener can tell when the oldest row (highest index, reverse list) nears.
|
||||
int _itemCount = 0;
|
||||
|
||||
bool _searchActive = false;
|
||||
String _searchQuery = '';
|
||||
List<ChatSearchMatch> _matches = const [];
|
||||
@@ -63,9 +69,24 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
super.initState();
|
||||
_chatBlocRef = context.read<ChatBloc>();
|
||||
_chatListBlocRef = context.read<ChatListBloc>();
|
||||
_positionsListener.itemPositions.addListener(_onScrollPositions);
|
||||
NotificationTasks.clearNotificationsForChat(widget.room.token);
|
||||
}
|
||||
|
||||
/// Loads the next older block once the top of the list comes into view.
|
||||
void _onScrollPositions() {
|
||||
final positions = _positionsListener.itemPositions.value;
|
||||
if (positions.isEmpty) return;
|
||||
// reverse:true → the highest index is the oldest message (top of screen).
|
||||
// Prefetch ~a screenful ahead so the next block is usually already merged
|
||||
// before the user reaches the top — the load stays invisible.
|
||||
final maxIndex = positions.map((p) => p.index).reduce(math.max);
|
||||
if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return;
|
||||
final data = _chatBlocRef?.state.data;
|
||||
if (data == null || !data.hasMoreOld || data.isLoadingOlder) return;
|
||||
_chatBlocRef?.loadOlder();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -93,6 +114,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
if (_subscribedRoute != null) {
|
||||
AppRoutes.chatRouteObserver.unsubscribe(this);
|
||||
}
|
||||
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
||||
_markAsReadFinal();
|
||||
_chatBlocRef?.leaveChat(widget.room.token);
|
||||
_searchTextController.dispose();
|
||||
@@ -281,22 +303,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
);
|
||||
}
|
||||
|
||||
if (response.data.length >= 200) {
|
||||
messages.insert(
|
||||
0,
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy(
|
||||
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
|
||||
'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de',
|
||||
),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
),
|
||||
);
|
||||
chronologicalMatchIndex.updateAll((_, v) => v + 1);
|
||||
}
|
||||
|
||||
final total = messages.length;
|
||||
_matchIndices
|
||||
..clear()
|
||||
@@ -373,9 +379,27 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
final items = _buildMessages(
|
||||
state.chatResponse!,
|
||||
).reversed.toList();
|
||||
// reverse:true renders index 0 at the bottom, so the top
|
||||
// marker (spinner / start-of-chat) goes at the end.
|
||||
if (state.isLoadingOlder) {
|
||||
items.add(const _LoadingOlderIndicator());
|
||||
} else if (!state.hasMoreOld) {
|
||||
items.add(
|
||||
ChatBubble(
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy(
|
||||
'Anfang des Chats',
|
||||
),
|
||||
chatData: widget.room,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
),
|
||||
);
|
||||
}
|
||||
_itemCount = items.length;
|
||||
return ScrollablePositionedList.builder(
|
||||
reverse: true,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemPositionsListener: _positionsListener,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, idx) => items[idx],
|
||||
);
|
||||
@@ -405,3 +429,22 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many rows before the oldest one the older-history prefetch kicks in.
|
||||
const _kLoadOlderPrefetchRows = 15;
|
||||
|
||||
class _LoadingOlderIndicator extends StatelessWidget {
|
||||
const _LoadingOlderIndicator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_participants/get_participants_cache.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
@@ -11,10 +12,10 @@ import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/avatar_actions_sheet.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/large_profile_picture_view.dart';
|
||||
import '../../../../widget/loading_spinner.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import 'participants_list_view.dart';
|
||||
import 'shared_items_view.dart';
|
||||
|
||||
class ChatInfo extends StatefulWidget {
|
||||
final GetRoomResponseObject room;
|
||||
@@ -26,6 +27,7 @@ class ChatInfo extends StatefulWidget {
|
||||
|
||||
class _ChatInfoState extends State<ChatInfo> {
|
||||
GetParticipantsResponse? participants;
|
||||
GetSharedItemsOverviewResponse? _sharesOverview;
|
||||
late bool _isFavorite;
|
||||
int _avatarVersion = 0;
|
||||
bool _avatarBusy = false;
|
||||
@@ -42,6 +44,16 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
});
|
||||
},
|
||||
);
|
||||
_preloadShares();
|
||||
}
|
||||
|
||||
Future<void> _preloadShares() async {
|
||||
try {
|
||||
final overview = await SharedItemsView.prefetchOverview(widget.room.token);
|
||||
if (mounted) setState(() => _sharesOverview = overview);
|
||||
} catch (_) {
|
||||
// Best-effort: the shares view loads on demand if the preload fails.
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshList() => context.read<ChatListBloc>().refresh();
|
||||
@@ -187,7 +199,15 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
if (participants == null)
|
||||
const Center(child: LoadingSpinner())
|
||||
const ListTile(
|
||||
leading: Icon(Icons.supervised_user_circle),
|
||||
title: Text('Mitglieder'),
|
||||
trailing: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: AppProgressIndicator.small(),
|
||||
),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: const Icon(Icons.supervised_user_circle),
|
||||
@@ -201,6 +221,15 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.perm_media_outlined),
|
||||
title: const Text('Medien und Dokumente'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => TalkNavigator.pushSplitView(
|
||||
context,
|
||||
SharedItemsView(widget.room, overview: _sharesOverview),
|
||||
),
|
||||
),
|
||||
if (_isFavorite)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../../utils/downloads/download_job.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
import '../../../../widget/downloads/download_trigger.dart';
|
||||
|
||||
const int _sharedItemsPageSize = 20;
|
||||
|
||||
/// The non-media shared-item categories Talk exposes, in tab order. `media` is
|
||||
/// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich
|
||||
/// objects without a downloadable file, so they have no place in this view.
|
||||
const List<_SharedCategory> _sharedCategories = [
|
||||
_SharedCategory('file', 'Dokumente', _SharedItemsLayout.list),
|
||||
_SharedCategory('voice', 'Sprachnachrichten', _SharedItemsLayout.list),
|
||||
_SharedCategory('audio', 'Audio', _SharedItemsLayout.list),
|
||||
_SharedCategory('recording', 'Aufnahmen', _SharedItemsLayout.list),
|
||||
_SharedCategory('other', 'Sonstiges', _SharedItemsLayout.list),
|
||||
];
|
||||
|
||||
class _SharedCategory {
|
||||
final String objectType;
|
||||
final String label;
|
||||
final _SharedItemsLayout layout;
|
||||
|
||||
const _SharedCategory(this.objectType, this.label, this.layout);
|
||||
}
|
||||
|
||||
bool _isVideoItem(GetChatResponseObject item) {
|
||||
final file = item.messageParameters?['file'];
|
||||
return file != null && _isVideoFile(file.name);
|
||||
}
|
||||
|
||||
/// One page of shared items plus the pagination cursor for the next request.
|
||||
/// Produced by [buildSharedItemsPage] so the in-view loader and the overview
|
||||
/// seed share the exact same paging semantics.
|
||||
class SharedItemsPage {
|
||||
final List<GetChatResponseObject> items;
|
||||
final int? lastKnownMessageId;
|
||||
final bool hasMore;
|
||||
|
||||
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
|
||||
}
|
||||
|
||||
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) => items
|
||||
.where((item) => item.messageParameters?['file']?.path != null)
|
||||
.toList();
|
||||
|
||||
SharedItemsPage buildSharedItemsPage(
|
||||
GetSharedItemsResponse response,
|
||||
int? previousMessageId,
|
||||
) {
|
||||
final lastGiven = response.lastGivenMessageId;
|
||||
final hasMore =
|
||||
response.items.length >= _sharedItemsPageSize &&
|
||||
lastGiven != null &&
|
||||
lastGiven != previousMessageId;
|
||||
return SharedItemsPage(
|
||||
_fileItems(response.items),
|
||||
hasMore ? lastGiven : previousMessageId,
|
||||
hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Seeds a tab from the overview payload. The pagination cursor is the oldest
|
||||
/// message id already shown; the per-type endpoint takes over from there via
|
||||
/// its `X-Chat-Last-Given` header. [hasMore] is a heuristic — a full page from
|
||||
/// the overview means there are probably older items to fetch on scroll.
|
||||
SharedItemsPage _seedFromOverview(List<GetChatResponseObject> rawItems) {
|
||||
final hasMore = rawItems.length >= _sharedItemsPageSize;
|
||||
final oldestId = rawItems.isEmpty
|
||||
? null
|
||||
: rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b);
|
||||
return SharedItemsPage(
|
||||
_fileItems(rawItems),
|
||||
hasMore ? oldestId : null,
|
||||
hasMore,
|
||||
);
|
||||
}
|
||||
|
||||
/// WhatsApp-style overview of everything shared in a chat. One tab per Talk
|
||||
/// share category, but only categories that actually contain a downloadable
|
||||
/// file are shown. Each tab paginates on its own via [GetSharedItems].
|
||||
class SharedItemsView extends StatefulWidget {
|
||||
final GetRoomResponseObject room;
|
||||
|
||||
/// Overview fetched ahead of time (by ChatInfo) so tabs render instantly.
|
||||
/// Null => this view fetches it itself on open.
|
||||
final GetSharedItemsOverviewResponse? overview;
|
||||
|
||||
const SharedItemsView(this.room, {this.overview, super.key});
|
||||
|
||||
/// Best-effort preload used by ChatInfo while the user is on the details
|
||||
/// screen, so opening this view needs no round-trip.
|
||||
static Future<GetSharedItemsOverviewResponse> prefetchOverview(
|
||||
String token,
|
||||
) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run();
|
||||
|
||||
@override
|
||||
State<SharedItemsView> createState() => _SharedItemsViewState();
|
||||
}
|
||||
|
||||
class _SharedItemsViewState extends State<SharedItemsView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
GetSharedItemsOverviewResponse? _overview;
|
||||
Object? _error;
|
||||
|
||||
List<(String, Widget)>? _tabs;
|
||||
TabController? _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_overview = widget.overview;
|
||||
if (_overview == null) {
|
||||
_load();
|
||||
} else {
|
||||
_prepareTabs();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _error = null);
|
||||
try {
|
||||
final overview = await SharedItemsView.prefetchOverview(widget.room.token);
|
||||
if (!mounted) return;
|
||||
_overview = overview;
|
||||
_prepareTabs();
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tabs and their controller exactly once. A scrollable [TabBar]
|
||||
/// crashes with "setState during build" when an ancestor rebuild swaps the
|
||||
/// [TabController] out mid-fling; owning a stable controller here avoids that.
|
||||
void _prepareTabs() {
|
||||
final overview = _overview;
|
||||
if (overview == null) return;
|
||||
|
||||
final tabs = <(String, Widget)>[];
|
||||
|
||||
// Media is one server category but two tabs: split the shared stream into
|
||||
// Bilder/Videos client-side. Both tabs page the same `media` endpoint.
|
||||
// Videos have no server preview here, so they render as a name list.
|
||||
final mediaSeed = _seedFromOverview(
|
||||
overview.itemsByType['media'] ?? const [],
|
||||
);
|
||||
if (mediaSeed.items.any((item) => !_isVideoItem(item))) {
|
||||
tabs.add((
|
||||
'Bilder',
|
||||
_SharedItemsTab(
|
||||
token: widget.room.token,
|
||||
objectType: 'media',
|
||||
layout: _SharedItemsLayout.grid,
|
||||
emptyLabel: 'Keine Bilder',
|
||||
initialPage: mediaSeed,
|
||||
itemFilter: (item) => !_isVideoItem(item),
|
||||
),
|
||||
));
|
||||
}
|
||||
if (mediaSeed.items.any(_isVideoItem)) {
|
||||
tabs.add((
|
||||
'Videos',
|
||||
_SharedItemsTab(
|
||||
token: widget.room.token,
|
||||
objectType: 'media',
|
||||
layout: _SharedItemsLayout.list,
|
||||
emptyLabel: 'Keine Videos',
|
||||
initialPage: mediaSeed,
|
||||
itemFilter: _isVideoItem,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
for (final category in _sharedCategories) {
|
||||
final raw = overview.itemsByType[category.objectType] ?? const [];
|
||||
if (_fileItems(raw).isEmpty) continue;
|
||||
tabs.add((
|
||||
category.label,
|
||||
_SharedItemsTab(
|
||||
token: widget.room.token,
|
||||
objectType: category.objectType,
|
||||
layout: category.layout,
|
||||
emptyLabel: 'Keine Einträge',
|
||||
initialPage: _seedFromOverview(raw),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
_tabs = tabs;
|
||||
_tabController = tabs.isEmpty
|
||||
? null
|
||||
: TabController(length: tabs.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_overview == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Medien und Dokumente')),
|
||||
body: _error != null
|
||||
? _ErrorState(message: errorToUserMessage(_error), onRetry: _load)
|
||||
: const Center(child: AppProgressIndicator.medium()),
|
||||
);
|
||||
}
|
||||
|
||||
final tabs = _tabs ?? const [];
|
||||
if (tabs.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Medien und Dokumente')),
|
||||
body: Center(
|
||||
child: Text(
|
||||
'Noch nichts geteilt',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final scrollable = tabs.length > 3;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Medien und Dokumente'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: scrollable,
|
||||
tabAlignment: scrollable ? TabAlignment.start : TabAlignment.fill,
|
||||
tabs: [for (final tab in tabs) Tab(text: tab.$1)],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [for (final tab in tabs) tab.$2],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _SharedItemsLayout { grid, list }
|
||||
|
||||
class _SharedItemsTab extends StatefulWidget {
|
||||
final String token;
|
||||
final String objectType;
|
||||
final _SharedItemsLayout layout;
|
||||
final String emptyLabel;
|
||||
final SharedItemsPage? initialPage;
|
||||
|
||||
/// Optional client-side filter. Used to carve the combined `media` stream
|
||||
/// into separate "Bilder" and "Videos" tabs; the pagination cursor still
|
||||
/// tracks the full (unfiltered) stream.
|
||||
final bool Function(GetChatResponseObject)? itemFilter;
|
||||
|
||||
const _SharedItemsTab({
|
||||
required this.token,
|
||||
required this.objectType,
|
||||
required this.layout,
|
||||
required this.emptyLabel,
|
||||
this.initialPage,
|
||||
this.itemFilter,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SharedItemsTab> createState() => _SharedItemsTabState();
|
||||
}
|
||||
|
||||
class _SharedItemsTabState extends State<_SharedItemsTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
// When a filtered tab keeps pulling pages that contain none of its kind, stop
|
||||
// auto-filling after this many empty pages so a video-sparse chat does not
|
||||
// page endlessly; the user can still scroll to fetch more by hand.
|
||||
static const int _maxEmptyAutoFills = 5;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<GetChatResponseObject> _items = [];
|
||||
|
||||
bool _loading = false;
|
||||
bool _initialLoaded = false;
|
||||
bool _hasMore = true;
|
||||
int? _lastKnownMessageId;
|
||||
int _emptyAutoFills = 0;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
List<GetChatResponseObject> _applyFilter(List<GetChatResponseObject> items) =>
|
||||
widget.itemFilter == null
|
||||
? items
|
||||
: items.where(widget.itemFilter!).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
final prefetch = widget.initialPage;
|
||||
if (prefetch != null) {
|
||||
_items.addAll(_applyFilter(prefetch.items));
|
||||
_lastKnownMessageId = prefetch.lastKnownMessageId;
|
||||
_hasMore = prefetch.hasMore;
|
||||
_initialLoaded = true;
|
||||
_scheduleFillCheck();
|
||||
} else {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final position = _scrollController.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 400) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/// After layout, keep loading while the (possibly filtered) content is too
|
||||
/// short to scroll — otherwise scroll-based paging could never kick in.
|
||||
void _scheduleFillCheck() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _loading || !_hasMore) return;
|
||||
if (_emptyAutoFills >= _maxEmptyAutoFills) return;
|
||||
if (!_scrollController.hasClients) return;
|
||||
if (_scrollController.position.maxScrollExtent > 0) return;
|
||||
_loadMore();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loading || !_hasMore) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final response = await GetSharedItems(
|
||||
widget.token,
|
||||
objectType: widget.objectType,
|
||||
limit: _sharedItemsPageSize,
|
||||
lastKnownMessageId: _lastKnownMessageId,
|
||||
).run();
|
||||
if (!mounted) return;
|
||||
final page = buildSharedItemsPage(response, _lastKnownMessageId);
|
||||
final filtered = _applyFilter(page.items);
|
||||
setState(() {
|
||||
_items.addAll(filtered);
|
||||
_lastKnownMessageId = page.lastKnownMessageId;
|
||||
_hasMore = page.hasMore;
|
||||
_initialLoaded = true;
|
||||
_loading = false;
|
||||
_emptyAutoFills = filtered.isEmpty ? _emptyAutoFills + 1 : 0;
|
||||
});
|
||||
_scheduleFillCheck();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loading = false;
|
||||
_initialLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retry() {
|
||||
setState(() {
|
||||
_hasMore = true;
|
||||
_error = null;
|
||||
});
|
||||
return _loadMore();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
if (!_initialLoaded && _loading) {
|
||||
return const Center(child: AppProgressIndicator.medium());
|
||||
}
|
||||
|
||||
if (_items.isEmpty && _error != null) {
|
||||
return _ErrorState(message: errorToUserMessage(_error), onRetry: _retry);
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.emptyLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final trailingLoader = _loading && _hasMore
|
||||
? const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: AppProgressIndicator.small()),
|
||||
),
|
||||
)
|
||||
: const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
|
||||
// Stop this list's scroll notifications from bubbling into the enclosing
|
||||
// TabBarView, which otherwise mis-syncs its indicator and can crash with
|
||||
// "setState during build" on a fling. Our own paging uses the controller
|
||||
// listener, not notifications, so nothing here depends on them bubbling.
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (_) => true,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
if (widget.layout == _SharedItemsLayout.grid)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _SharedItemTile(
|
||||
item: _items[index],
|
||||
layout: _SharedItemsLayout.grid,
|
||||
),
|
||||
childCount: _items.length,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _SharedItemTile(
|
||||
item: _items[index],
|
||||
layout: _SharedItemsLayout.list,
|
||||
),
|
||||
childCount: _items.length,
|
||||
),
|
||||
),
|
||||
trailingLoader,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SharedItemTile extends StatefulWidget {
|
||||
final GetChatResponseObject item;
|
||||
final _SharedItemsLayout layout;
|
||||
|
||||
const _SharedItemTile({required this.item, required this.layout});
|
||||
|
||||
@override
|
||||
State<_SharedItemTile> createState() => _SharedItemTileState();
|
||||
}
|
||||
|
||||
class _SharedItemTileState extends State<_SharedItemTile>
|
||||
with DownloadTrigger<_SharedItemTile> {
|
||||
RichObjectString get _file => widget.item.messageParameters!['file']!;
|
||||
|
||||
@override
|
||||
String? get downloadRemotePath => _file.path;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initDownloadTrigger();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeDownloadTrigger();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (guardDemoAction(context)) return;
|
||||
if (isDownloading) {
|
||||
confirmCancelDownload();
|
||||
} else {
|
||||
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file));
|
||||
}
|
||||
}
|
||||
|
||||
String get _previewUrl =>
|
||||
'https://${EndpointData().nextcloud().full()}'
|
||||
'/index.php/core/preview?fileId=${_file.id}&x=300&y=300&a=1';
|
||||
|
||||
bool get _isVideo => _isVideoFile(_file.name);
|
||||
|
||||
bool get _isDownloading => downloadJob?.status.value is DownloadInProgress;
|
||||
|
||||
double? get _downloadProgress {
|
||||
final status = downloadJob?.status.value;
|
||||
if (status is! DownloadInProgress) return null;
|
||||
return status.percent <= 0 ? null : status.percent / 100;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.layout == _SharedItemsLayout.grid
|
||||
? _buildGrid(context)
|
||||
: _buildList(context);
|
||||
|
||||
Widget _buildGrid(BuildContext context) => GestureDetector(
|
||||
onTap: _onTap,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: _previewUrl,
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorListener: (_) {},
|
||||
placeholder: (context, url) =>
|
||||
const Center(child: AppProgressIndicator.small()),
|
||||
// Video thumbnails only exist when the server's preview provider
|
||||
// (ffmpeg) generated one; fall back to a film icon rather than a
|
||||
// broken-image glyph when it did not.
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
_isVideo
|
||||
? Icons.movie_outlined
|
||||
: Icons.image_not_supported_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isVideo)
|
||||
const Center(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Icon(Icons.play_arrow, color: Colors.white, size: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isDownloading)
|
||||
const ColoredBox(
|
||||
color: Colors.black38,
|
||||
child: Center(
|
||||
child: AppProgressIndicator.small(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildList(BuildContext context) => ListTile(
|
||||
leading: Icon(_iconForFile(_file.name), size: 36),
|
||||
title: Text(_file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
'${widget.item.actorDisplayName} · '
|
||||
'${DateTime.fromMillisecondsSinceEpoch(widget.item.timestamp * 1000).formatDateShort()}',
|
||||
),
|
||||
trailing: _isDownloading
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: _downloadProgress,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: _onTap,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVideoFile(String name) {
|
||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
||||
const videoExtensions = {
|
||||
'mp4',
|
||||
'mov',
|
||||
'mkv',
|
||||
'webm',
|
||||
'avi',
|
||||
'm4v',
|
||||
'3gp',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
'wmv',
|
||||
'flv',
|
||||
};
|
||||
return videoExtensions.contains(ext);
|
||||
}
|
||||
|
||||
IconData _iconForFile(String name) {
|
||||
if (_isVideoFile(name)) return Icons.movie_outlined;
|
||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return Icons.picture_as_pdf_outlined;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
case 'odt':
|
||||
case 'rtf':
|
||||
case 'txt':
|
||||
return Icons.description_outlined;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv':
|
||||
return Icons.table_chart_outlined;
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
case 'odp':
|
||||
return Icons.slideshow_outlined;
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
return Icons.folder_zip_outlined;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'm4a':
|
||||
case 'ogg':
|
||||
return Icons.audiotrack_outlined;
|
||||
default:
|
||||
return Icons.insert_drive_file_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorState extends StatelessWidget {
|
||||
final String message;
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
const _ErrorState({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import '../../../../theming/app_theme.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/placeholder_view.dart';
|
||||
import '../../../../widget/prosemirror/pm_json_view.dart';
|
||||
import '../../../../widget/route_transition_gate.dart';
|
||||
import 'ticker_content_card.dart';
|
||||
import 'ticker_updated_bar.dart';
|
||||
|
||||
@@ -187,7 +188,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
|
||||
text: 'Das Dokument konnte nicht geladen werden.',
|
||||
);
|
||||
}
|
||||
return SfPdfViewer.memory(bytes);
|
||||
return RouteTransitionGate(
|
||||
builder: (context) => SfPdfViewer.memory(bytes),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
|
||||
final RegExp _whitespaceRun = RegExp(r'\s+');
|
||||
|
||||
/// Collapses any line-break or whitespace run to a single space and trims.
|
||||
/// Returns null when input is null or fully whitespace. Webuntis sometimes
|
||||
/// returns multi-line values like "A30\n4" — this normalizes those so labels
|
||||
/// render on a single line.
|
||||
String? collapseWhitespace(String? s) {
|
||||
if (s == null) return null;
|
||||
final cleaned = s.replaceAll(_whitespaceRun, ' ').trim();
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
}
|
||||
|
||||
/// "7a, 7b" — shared by the calendar tile factory and the home-widget mapper
|
||||
/// so both surfaces render identical class labels on teacher plans.
|
||||
extension LessonClassLabel on McTimetableEntry {
|
||||
String? get classLabel => collapseWhitespace(classNames.join(', '));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
|
||||
/// Combines back-to-back lessons with identical subject/room/teacher/status
|
||||
@@ -44,6 +46,9 @@ class LessonMerger {
|
||||
b.teachers.firstOrNull?.shortName) {
|
||||
return false;
|
||||
}
|
||||
// Relevant für Lehrerpläne: gleicher Lehrer/Fach/Raum, aber verschiedene
|
||||
// Klassen dürfen nicht zu einem Block verschmelzen.
|
||||
if (!listEquals(a.classNames, b.classNames)) return false;
|
||||
if (a.status != b.status) return false;
|
||||
// Lower bound on the gap — without it, two identical-metadata lessons that
|
||||
// overlap in time would silently collapse into one.
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'lesson_color.dart';
|
||||
import 'lesson_labels.dart';
|
||||
import 'lesson_merger.dart';
|
||||
import 'lesson_status.dart';
|
||||
import 'lesson_type_label.dart';
|
||||
@@ -23,6 +24,10 @@ class TimetableAppointmentFactory {
|
||||
final TimetableSettings settings;
|
||||
final DateTime now;
|
||||
|
||||
/// Teacher plans (a teacher's own plan or a foreign teacher view) show the
|
||||
/// class on the tile instead of the teacher's own name.
|
||||
final bool showClassInsteadOfTeacher;
|
||||
|
||||
TimetableAppointmentFactory({
|
||||
required this.lessons,
|
||||
required this.customEvents,
|
||||
@@ -30,6 +35,7 @@ class TimetableAppointmentFactory {
|
||||
required this.settings,
|
||||
required this.now,
|
||||
this.holidays = const [],
|
||||
this.showClassInsteadOfTeacher = false,
|
||||
});
|
||||
|
||||
List<Appointment> build() {
|
||||
@@ -130,7 +136,7 @@ class TimetableAppointmentFactory {
|
||||
location: event.description.trim().isEmpty
|
||||
? null
|
||||
: event.description.trim(),
|
||||
subject: _collapseWhitespace(event.title) ?? event.title,
|
||||
subject: collapseWhitespace(event.title) ?? event.title,
|
||||
recurrenceRule: parsed.rule,
|
||||
recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates,
|
||||
color:
|
||||
@@ -222,7 +228,7 @@ class TimetableAppointmentFactory {
|
||||
TimetableNameMode.longName => lookup?.longName ?? subjectShort,
|
||||
TimetableNameMode.alternateName => lookup?.longName ?? subjectShort,
|
||||
};
|
||||
final collapsed = _collapseWhitespace(name);
|
||||
final collapsed = collapseWhitespace(name);
|
||||
if (collapsed != null) return collapsed;
|
||||
}
|
||||
// Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc.
|
||||
@@ -233,10 +239,13 @@ class TimetableAppointmentFactory {
|
||||
|
||||
String _locationLabel(McTimetableEntry lesson) {
|
||||
final roomName =
|
||||
_collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
||||
final teacherName =
|
||||
_teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
|
||||
return '$roomName\n$teacherName';
|
||||
collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
||||
// Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück.
|
||||
final secondLine =
|
||||
(showClassInsteadOfTeacher ? lesson.classLabel : null) ??
|
||||
_teacherLabel(lesson.teachers.firstOrNull) ??
|
||||
'Unbekannt';
|
||||
return '$roomName\n$secondLine';
|
||||
}
|
||||
|
||||
/// Backend serves teachers with their full display name ("Stefan Müller"),
|
||||
@@ -245,27 +254,11 @@ class TimetableAppointmentFactory {
|
||||
/// overview; the detail sheet still renders the full name as a subtitle.
|
||||
static String? _teacherLabel(McTimetableTeacher? teacher) {
|
||||
if (teacher == null) return null;
|
||||
final display = _collapseWhitespace(teacher.displayName);
|
||||
final display = collapseWhitespace(teacher.displayName);
|
||||
if (display != null && display.isNotEmpty) {
|
||||
final parts = display.split(' ');
|
||||
return parts.isEmpty ? display : parts.last;
|
||||
}
|
||||
return _collapseWhitespace(teacher.shortName);
|
||||
}
|
||||
|
||||
/// Collapses any line-break or whitespace run to a single space and trims.
|
||||
/// Returns null when input is null or fully whitespace. Webuntis sometimes
|
||||
/// returns multi-line room names like "A30\n4" — this normalizes those so
|
||||
/// the tile renders the room on a single line.
|
||||
static String? _collapseWhitespace(String? s) {
|
||||
if (s == null) return null;
|
||||
final cleaned = s
|
||||
.replaceAll('\r\n', ' ')
|
||||
.replaceAll('\n', ' ')
|
||||
.replaceAll('\r', ' ')
|
||||
.replaceAll('\t', ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
return collapseWhitespace(teacher.shortName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +101,8 @@ class _TimetableState extends State<Timetable> {
|
||||
final loadableState = context.watch<TimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final canViewForeign = capabilities.canViewForeignTimetables;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||
@@ -166,6 +165,7 @@ class _TimetableState extends State<Timetable> {
|
||||
),
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: capabilities.isTeacher,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -217,6 +217,8 @@ class _TimetableState extends State<Timetable> {
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||
customEvents: const [],
|
||||
showClassInsteadOfTeacher:
|
||||
selected.type == TimetableElementType.teacher,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
@@ -27,6 +28,10 @@ class TimetableCalendarView extends StatefulWidget {
|
||||
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||
final List<CustomTimetableEvent> customEvents;
|
||||
|
||||
/// True for teacher plans — tiles then show the class instead of the
|
||||
/// teacher name (see [TimetableAppointmentFactory.showClassInsteadOfTeacher]).
|
||||
final bool showClassInsteadOfTeacher;
|
||||
|
||||
const TimetableCalendarView({
|
||||
super.key,
|
||||
required this.state,
|
||||
@@ -34,6 +39,7 @@ class TimetableCalendarView extends StatefulWidget {
|
||||
required this.onAppointmentTap,
|
||||
this.onCreateEvent,
|
||||
this.customEvents = const [],
|
||||
this.showClassInsteadOfTeacher = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -45,9 +51,9 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
GlobalKey<CustomWorkWeekCalendarState>();
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
TimetableSettings? _lastTimetableSettings;
|
||||
List<CustomTimetableEvent>? _lastCustomEvents;
|
||||
// TimetableSettings and List define no `==`, so record equality degrades to
|
||||
// the same identity checks the cache always used.
|
||||
(int, TimetableSettings, List<CustomTimetableEvent>, bool)? _cacheKey;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
@@ -62,15 +68,16 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings) &&
|
||||
identical(_lastCustomEvents, widget.customEvents)) {
|
||||
final key = (
|
||||
state.dataVersion,
|
||||
timetableSettings,
|
||||
widget.customEvents,
|
||||
widget.showClassInsteadOfTeacher,
|
||||
);
|
||||
if (_cachedAppointments != null && _cacheKey == key) {
|
||||
return _cachedAppointments!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
_lastTimetableSettings = timetableSettings;
|
||||
_lastCustomEvents = widget.customEvents;
|
||||
_cacheKey = key;
|
||||
|
||||
return _cachedAppointments = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
@@ -79,6 +86,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
holidays: state.schoolHolidays?.result ?? const [],
|
||||
settings: timetableSettings,
|
||||
now: DateTime.now(),
|
||||
showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher,
|
||||
).build();
|
||||
}
|
||||
|
||||
@@ -105,7 +113,12 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build();
|
||||
|
||||
final (minDate, maxDate) = _scrollBounds(state);
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final (minDate, maxDate) = _scrollBounds(
|
||||
state,
|
||||
pastDays: capabilities.timetablePastDays,
|
||||
futureDays: capabilities.timetableFutureDays,
|
||||
);
|
||||
|
||||
return CustomWorkWeekCalendar(
|
||||
key: _calendarKey,
|
||||
@@ -122,17 +135,21 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
|
||||
/// school year (or a stale persisted bound) would let the user scroll
|
||||
/// further, we never expose more than this much around the current week.
|
||||
static const int _maxWeeksBack = 4;
|
||||
static const int _maxWeeksForward = 2;
|
||||
|
||||
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
|
||||
/// Starts from the Webuntis school year (or a tight window when that hasn't
|
||||
/// loaded yet), tightens by anything the bloc has learned from past denials,
|
||||
/// and finally clamps to a fixed window around today.
|
||||
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
||||
/// Starts from the (server-narrowed) Webuntis school year — or a tight window
|
||||
/// when that hasn't loaded yet —, tightens by anything the bloc has learned
|
||||
/// from past denials, and finally clamps to the window Connect grants via
|
||||
/// [CapabilitiesCubit.timetablePastDays]/[CapabilitiesCubit.timetableFutureDays].
|
||||
///
|
||||
/// The capability clamp mirrors the server: `null` means unlimited (no client
|
||||
/// clamp at all), and a given day count is widened to at least cover the
|
||||
/// current Mon–Sun week — so the two windows always coincide and the clamp
|
||||
/// can never invert.
|
||||
(DateTime, DateTime) _scrollBounds(
|
||||
TimetableState state, {
|
||||
required int? pastDays,
|
||||
required int? futureDays,
|
||||
}) {
|
||||
final year = state.schoolyear;
|
||||
final DateTime baseMin;
|
||||
final DateTime baseMax;
|
||||
@@ -154,39 +171,43 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final todayMonday = _mondayOf(DateTime.now());
|
||||
final cappedMin = effectiveMin.isBefore(
|
||||
todayMonday.subtractDays(_maxWeeksBack * 7),
|
||||
)
|
||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||
final today = _startOfDay(DateTime.now());
|
||||
final todayMonday = _mondayOf(today);
|
||||
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
|
||||
final capMin = pastDays == null
|
||||
? null
|
||||
: _earlier(today.subtractDays(pastDays), todayMonday);
|
||||
final capMax = futureDays == null
|
||||
? null
|
||||
: _later(today.addDays(futureDays), currentWeekEnd);
|
||||
final cappedMin = capMin != null && effectiveMin.isBefore(capMin)
|
||||
? capMin
|
||||
: effectiveMin;
|
||||
final cappedMax = effectiveMax.isAfter(
|
||||
todayMonday.addDays(_maxWeeksForward * 7 + 6),
|
||||
)
|
||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||
final cappedMax = capMax != null && effectiveMax.isAfter(capMax)
|
||||
? capMax
|
||||
: effectiveMax;
|
||||
// When the resulting range does not cover the current week — the summer gap
|
||||
// between two school years, or a stale persisted bound — fall back to the
|
||||
// full fixed window around today. Otherwise the PageView clamps the initial
|
||||
// page to the last week before the holidays (hiding the "Schulfrei" region)
|
||||
// and forward scrolling collapses to the current week only.
|
||||
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
|
||||
// current week, widened to whatever the capabilities still allow. Otherwise
|
||||
// the PageView clamps the initial page to the last week before the holidays
|
||||
// (hiding the "Schulfrei" region) and forward scrolling collapses to the
|
||||
// current week only.
|
||||
final outsideRange =
|
||||
cappedMax.isBefore(todayMonday) || cappedMin.isAfter(currentWeekEnd);
|
||||
final finalMin = outsideRange
|
||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||
: cappedMin;
|
||||
final finalMax = outsideRange
|
||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||
: cappedMax;
|
||||
final finalMin = outsideRange ? (capMin ?? todayMonday) : cappedMin;
|
||||
final finalMax = outsideRange ? (capMax ?? currentWeekEnd) : cappedMax;
|
||||
final daysToMonday =
|
||||
(DateTime.monday - finalMin.weekday) % DateTime.daysPerWeek;
|
||||
final mondayMin = finalMin.addDays(daysToMonday);
|
||||
return (mondayMin, finalMax);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) {
|
||||
final monday = d.subtractDays(d.weekday - 1);
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
static DateTime _mondayOf(DateTime d) =>
|
||||
_startOfDay(d.subtractDays(d.weekday - 1));
|
||||
|
||||
static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||
|
||||
static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b;
|
||||
|
||||
static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../utils/downloads/download_job.dart';
|
||||
import '../../utils/downloads/download_manager.dart';
|
||||
import '../../utils/haptics.dart';
|
||||
import 'downloads_sheet.dart';
|
||||
import 'stale_download_guard.dart';
|
||||
|
||||
/// Decides whether a just-finished download should open straight in the viewer.
|
||||
///
|
||||
@@ -198,9 +199,13 @@ class _DownloadTrayHostState extends State<DownloadTrayHost>
|
||||
|
||||
void _openJob(DownloadJob job) {
|
||||
final path = job.localPath;
|
||||
_manager.markOpened(job);
|
||||
final ctx = AppRoutes.overlayContext;
|
||||
if (path == null || ctx == null) return;
|
||||
if (path == null || ctx == null) {
|
||||
_manager.markOpened(job);
|
||||
return;
|
||||
}
|
||||
if (!ensureDownloadStillExists(ctx, job)) return;
|
||||
_manager.markOpened(job);
|
||||
Haptics.success();
|
||||
AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../view/pages/files/data/file_type_icon.dart';
|
||||
import '../centered_leading.dart';
|
||||
import '../details_bottom_sheet.dart';
|
||||
import '../info_dialog.dart';
|
||||
import 'stale_download_guard.dart';
|
||||
|
||||
/// Overview of all active and finished-but-unopened downloads. Lets the user
|
||||
/// open/switch between finished files, cancel running ones and retry failures.
|
||||
@@ -86,8 +87,11 @@ class _DownloadsListState extends State<_DownloadsList> {
|
||||
void _open(DownloadJob job) {
|
||||
final path = job.localPath;
|
||||
if (path == null) return;
|
||||
Haptics.success();
|
||||
// Pop the sheet first so a stale-file dialog lands on the underlying
|
||||
// screen instead of an emptied sheet.
|
||||
Navigator.of(widget.sheetContext).pop();
|
||||
if (!ensureDownloadStillExists(widget.rootContext, job)) return;
|
||||
Haptics.success();
|
||||
DownloadManager.instance.markOpened(job);
|
||||
AppRoutes.openFileViewer(
|
||||
widget.rootContext,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../utils/downloads/download_job.dart';
|
||||
import '../../utils/downloads/download_manager.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../info_dialog.dart';
|
||||
|
||||
/// Verifies a finished download's cache file is still on disk before opening.
|
||||
///
|
||||
/// Downloads live in the app's temp dir, which Android may clear at any time
|
||||
/// (storage pressure, "Cache leeren") — while completion notifications and
|
||||
/// tray entries stay tappable indefinitely. Returns true when the file exists;
|
||||
/// otherwise drops the stale job (incl. its notification) and offers a
|
||||
/// re-download when the remote path is known (it isn't for notification taps
|
||||
/// whose task metadata got lost).
|
||||
bool ensureDownloadStillExists(BuildContext context, DownloadJob job) {
|
||||
final path = job.localPath;
|
||||
if (path == null || File(path).existsSync()) return true;
|
||||
|
||||
DownloadManager.instance.markOpened(job);
|
||||
if (job.remotePath.isNotEmpty) {
|
||||
ConfirmDialog(
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
content:
|
||||
'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
|
||||
confirmButton: 'Herunterladen',
|
||||
onConfirm: () => unawaited(DownloadManager.instance.retry(job)),
|
||||
).asDialog(context);
|
||||
} else {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -14,8 +14,11 @@ import 'package:share_plus/share_plus.dart';
|
||||
import '../routing/app_routes.dart';
|
||||
import '../share_intent/remote_file_ref.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../utils/downloads/download_manager.dart';
|
||||
import 'app_progress_indicator.dart';
|
||||
import 'async_action_button.dart';
|
||||
import 'centered_leading.dart';
|
||||
import 'confirm_dialog.dart';
|
||||
import 'file_viewer/code_line.dart';
|
||||
import 'file_viewer/deferred_pdf_viewer.dart';
|
||||
import 'file_viewer/file_kind.dart';
|
||||
@@ -82,9 +85,43 @@ class _FileViewerState extends State<FileViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Android may clear the cache dir behind an open viewer at any time —
|
||||
/// verify the file is still there before handing its path to an action.
|
||||
bool _ensureLocalFile() {
|
||||
if (File(widget.path).existsSync()) return true;
|
||||
final remote = widget.remoteFile;
|
||||
if (remote == null) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
ConfirmDialog(
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
content:
|
||||
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
|
||||
confirmButton: 'Herunterladen',
|
||||
onConfirm: () {
|
||||
// Pop the viewer before starting so the fresh download auto-opens.
|
||||
Navigator.of(context).pop();
|
||||
unawaited(
|
||||
DownloadManager.instance.start(
|
||||
remotePath: remote.path,
|
||||
name: remote.name,
|
||||
remoteFile: remote,
|
||||
),
|
||||
);
|
||||
},
|
||||
).asDialog(context);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _handleAction(FileViewingActions value) async {
|
||||
switch (value) {
|
||||
case FileViewingActions.openExternal:
|
||||
if (!_ensureLocalFile()) return;
|
||||
AppRoutes.openFileViewer(
|
||||
context,
|
||||
widget.path,
|
||||
@@ -99,16 +136,21 @@ class _FileViewerState extends State<FileViewer> {
|
||||
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
|
||||
break;
|
||||
case FileViewingActions.share:
|
||||
if (!_ensureLocalFile()) return;
|
||||
unawaited(
|
||||
SharePlus.instance.share(
|
||||
runWithErrorDialog(
|
||||
context,
|
||||
() => SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(widget.path)],
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.save:
|
||||
if (!_ensureLocalFile()) return;
|
||||
try {
|
||||
final source = File(widget.path);
|
||||
final size = await source.length();
|
||||
|
||||
@@ -3,51 +3,17 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
|
||||
import '../app_progress_indicator.dart';
|
||||
import '../route_transition_gate.dart';
|
||||
|
||||
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
||||
/// animation. Defer until the route enter animation completes.
|
||||
class DeferredPdfViewer extends StatefulWidget {
|
||||
/// SfPdfViewer asserts on `localToGlobal` if laid out while a route
|
||||
/// transition's fresh `RenderTransform` has no size yet. Mount it only while
|
||||
/// the route is at rest (see [RouteTransitionGate]).
|
||||
class DeferredPdfViewer extends StatelessWidget {
|
||||
const DeferredPdfViewer({super.key, required this.path});
|
||||
final String path;
|
||||
|
||||
@override
|
||||
State<DeferredPdfViewer> createState() => _DeferredPdfViewerState();
|
||||
}
|
||||
|
||||
class _DeferredPdfViewerState extends State<DeferredPdfViewer> {
|
||||
bool _ready = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_ready || _routeAnimation != null) return;
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (animation == null || animation.isCompleted) {
|
||||
_ready = true;
|
||||
return;
|
||||
}
|
||||
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
||||
}
|
||||
|
||||
void _onAnimationStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed && mounted) {
|
||||
setState(() => _ready = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_ready) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
return SfPdfViewer.file(File(widget.path));
|
||||
}
|
||||
Widget build(BuildContext context) => RouteTransitionGate(
|
||||
builder: (context) => SfPdfViewer.file(File(path)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_progress_indicator.dart';
|
||||
|
||||
/// Builds [builder]'s subtree only while the enclosing route is at rest — i.e.
|
||||
/// neither its own enter/exit animation nor its secondary (route-pushed-on-top)
|
||||
/// transition is running.
|
||||
///
|
||||
/// Some widgets (notably `SfPdfViewer`) call `localToGlobal` during layout and
|
||||
/// crash with `RenderBox was not laid out` when an ancestor page-transition
|
||||
/// `RenderTransform` is mid-first-layout. Those transforms are inserted freshly
|
||||
/// whenever a transition *starts* — not only on the initial push, but also on
|
||||
/// pop and when another page is pushed on top. The gate therefore swaps the
|
||||
/// subtree for [placeholder] for the duration of any transition; the status
|
||||
/// listener fires before that frame's layout, so the fragile subtree is gone
|
||||
/// before the new transform lays out.
|
||||
class RouteTransitionGate extends StatefulWidget {
|
||||
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
/// Shown while the route is transitioning. Defaults to a centered large
|
||||
/// progress indicator.
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<RouteTransitionGate> createState() => _RouteTransitionGateState();
|
||||
}
|
||||
|
||||
class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
||||
Animation<double>? _animation;
|
||||
Animation<double>? _secondaryAnimation;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final route = ModalRoute.of(context);
|
||||
_swapListener(route?.animation, _animation, (a) => _animation = a);
|
||||
_swapListener(
|
||||
route?.secondaryAnimation,
|
||||
_secondaryAnimation,
|
||||
(a) => _secondaryAnimation = a,
|
||||
);
|
||||
}
|
||||
|
||||
void _swapListener(
|
||||
Animation<double>? next,
|
||||
Animation<double>? current,
|
||||
void Function(Animation<double>?) assign,
|
||||
) {
|
||||
if (identical(next, current)) return;
|
||||
current?.removeStatusListener(_onAnimationStatus);
|
||||
assign(next?..addStatusListener(_onAnimationStatus));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animation?.removeStatusListener(_onAnimationStatus);
|
||||
_secondaryAnimation?.removeStatusListener(_onAnimationStatus);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _transitioning =>
|
||||
_isAnimating(_animation?.status) ||
|
||||
_isAnimating(_secondaryAnimation?.status);
|
||||
|
||||
static bool _isAnimating(AnimationStatus? status) =>
|
||||
status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
||||
|
||||
void _onAnimationStatus(AnimationStatus status) {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_transitioning) {
|
||||
return widget.placeholder ??
|
||||
const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
return widget.builder(context);
|
||||
}
|
||||
}
|
||||
@@ -230,11 +230,15 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
|
||||
final pending = _pendingAvatars.putIfAbsent(url, () {
|
||||
final future = _fetch(url);
|
||||
future.whenComplete(() {
|
||||
// Cleanup hangs off an error-neutralised copy: whenComplete on `future`
|
||||
// itself returns a second future that forwards the error unawaited.
|
||||
unawaited(
|
||||
future.then<void>((_) {}, onError: (_) {}).whenComplete(() {
|
||||
if (identical(_pendingAvatars[url], future)) {
|
||||
_pendingAvatars.remove(url);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
return future;
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ abstract class WidgetLesson with _$WidgetLesson {
|
||||
required String subjectShort,
|
||||
String? subjectLong,
|
||||
String? room,
|
||||
/// On teacher accounts this carries the class label ("7a") instead of the
|
||||
/// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`;
|
||||
/// [originalTeacher] is null in that case.
|
||||
String? teacher,
|
||||
String? originalTeacher,
|
||||
required WidgetLessonStatus status,
|
||||
@@ -61,6 +64,20 @@ abstract class WidgetPeriod with _$WidgetPeriod {
|
||||
_$WidgetPeriodFromJson(json);
|
||||
}
|
||||
|
||||
/// Per-day metadata for the week payload, so native renderers can derive a
|
||||
/// single day's view (including its holiday state) without a day payload.
|
||||
@freezed
|
||||
abstract class WidgetDayInfo with _$WidgetDayInfo {
|
||||
const factory WidgetDayInfo({
|
||||
required DateTime date,
|
||||
@Default(false) bool isHoliday,
|
||||
String? holidayName,
|
||||
}) = _WidgetDayInfo;
|
||||
|
||||
factory WidgetDayInfo.fromJson(Map<String, Object?> json) =>
|
||||
_$WidgetDayInfoFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class WidgetTimetableData with _$WidgetTimetableData {
|
||||
const factory WidgetTimetableData({
|
||||
@@ -73,6 +90,8 @@ abstract class WidgetTimetableData with _$WidgetTimetableData {
|
||||
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
|
||||
@Default(false) bool isHoliday,
|
||||
String? holidayName,
|
||||
/// Week payload only: one entry per day of the covered window.
|
||||
@Default(<WidgetDayInfo>[]) List<WidgetDayInfo> days,
|
||||
}) = _WidgetTimetableData;
|
||||
|
||||
factory WidgetTimetableData.fromJson(Map<String, Object?> json) =>
|
||||
|
||||
@@ -593,13 +593,283 @@ as int,
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$WidgetDayInfo {
|
||||
|
||||
DateTime get date; bool get isHoliday; String? get holidayName;
|
||||
/// Create a copy of WidgetDayInfo
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$WidgetDayInfoCopyWith<WidgetDayInfo> get copyWith => _$WidgetDayInfoCopyWithImpl<WidgetDayInfo>(this as WidgetDayInfo, _$identity);
|
||||
|
||||
/// Serializes this WidgetDayInfo to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $WidgetDayInfoCopyWith<$Res> {
|
||||
factory $WidgetDayInfoCopyWith(WidgetDayInfo value, $Res Function(WidgetDayInfo) _then) = _$WidgetDayInfoCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
DateTime date, bool isHoliday, String? holidayName
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$WidgetDayInfoCopyWithImpl<$Res>
|
||||
implements $WidgetDayInfoCopyWith<$Res> {
|
||||
_$WidgetDayInfoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final WidgetDayInfo _self;
|
||||
final $Res Function(WidgetDayInfo) _then;
|
||||
|
||||
/// Create a copy of WidgetDayInfo
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? date = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
|
||||
as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [WidgetDayInfo].
|
||||
extension WidgetDayInfoPatterns on WidgetDayInfo {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _WidgetDayInfo value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetDayInfo() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _WidgetDayInfo value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetDayInfo():
|
||||
return $default(_that);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _WidgetDayInfo value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetDayInfo() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime date, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetDayInfo() when $default != null:
|
||||
return $default(_that.date,_that.isHoliday,_that.holidayName);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime date, bool isHoliday, String? holidayName) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetDayInfo():
|
||||
return $default(_that.date,_that.isHoliday,_that.holidayName);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime date, bool isHoliday, String? holidayName)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetDayInfo() when $default != null:
|
||||
return $default(_that.date,_that.isHoliday,_that.holidayName);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _WidgetDayInfo implements WidgetDayInfo {
|
||||
const _WidgetDayInfo({required this.date, this.isHoliday = false, this.holidayName});
|
||||
factory _WidgetDayInfo.fromJson(Map<String, dynamic> json) => _$WidgetDayInfoFromJson(json);
|
||||
|
||||
@override final DateTime date;
|
||||
@override@JsonKey() final bool isHoliday;
|
||||
@override final String? holidayName;
|
||||
|
||||
/// Create a copy of WidgetDayInfo
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$WidgetDayInfoCopyWith<_WidgetDayInfo> get copyWith => __$WidgetDayInfoCopyWithImpl<_WidgetDayInfo>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$WidgetDayInfoToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$WidgetDayInfoCopyWith<$Res> implements $WidgetDayInfoCopyWith<$Res> {
|
||||
factory _$WidgetDayInfoCopyWith(_WidgetDayInfo value, $Res Function(_WidgetDayInfo) _then) = __$WidgetDayInfoCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
DateTime date, bool isHoliday, String? holidayName
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$WidgetDayInfoCopyWithImpl<$Res>
|
||||
implements _$WidgetDayInfoCopyWith<$Res> {
|
||||
__$WidgetDayInfoCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _WidgetDayInfo _self;
|
||||
final $Res Function(_WidgetDayInfo) _then;
|
||||
|
||||
/// Create a copy of WidgetDayInfo
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? date = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
|
||||
return _then(_WidgetDayInfo(
|
||||
date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
|
||||
as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$WidgetTimetableData {
|
||||
|
||||
DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
|
||||
/// For the day variant: the rendered school day.
|
||||
/// For the week variant: the Monday of the rendered school week.
|
||||
DateTime get anchorDate; List<WidgetLesson> get lessons; List<WidgetPeriod> get periods; bool get isHoliday; String? get holidayName;
|
||||
DateTime get anchorDate; List<WidgetLesson> get lessons; List<WidgetPeriod> get periods; bool get isHoliday; String? get holidayName;/// Week payload only: one entry per day of the covered window.
|
||||
List<WidgetDayInfo> get days;
|
||||
/// Create a copy of WidgetTimetableData
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -612,16 +882,16 @@ $WidgetTimetableDataCopyWith<WidgetTimetableData> get copyWith => _$WidgetTimeta
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, days));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName);
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName,const DeepCollectionEquality().hash(days));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)';
|
||||
}
|
||||
|
||||
|
||||
@@ -632,7 +902,7 @@ abstract mixin class $WidgetTimetableDataCopyWith<$Res> {
|
||||
factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName
|
||||
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days
|
||||
});
|
||||
|
||||
|
||||
@@ -649,7 +919,7 @@ class _$WidgetTimetableDataCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of WidgetTimetableData
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable
|
||||
@@ -657,7 +927,8 @@ as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_n
|
||||
as List<WidgetLesson>,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable
|
||||
as List<WidgetPeriod>,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
|
||||
as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
as String?,days: null == days ? _self.days : days // ignore: cast_nullable_to_non_nullable
|
||||
as List<WidgetDayInfo>,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -742,10 +1013,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetTimetableData() when $default != null:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -763,10 +1034,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetTimetableData():
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -783,10 +1054,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _WidgetTimetableData() when $default != null:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -798,7 +1069,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
||||
@JsonSerializable()
|
||||
|
||||
class _WidgetTimetableData implements WidgetTimetableData {
|
||||
const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List<WidgetLesson> lessons, final List<WidgetPeriod> periods = const <WidgetPeriod>[], this.isHoliday = false, this.holidayName}): _lessons = lessons,_periods = periods;
|
||||
const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List<WidgetLesson> lessons, final List<WidgetPeriod> periods = const <WidgetPeriod>[], this.isHoliday = false, this.holidayName, final List<WidgetDayInfo> days = const <WidgetDayInfo>[]}): _lessons = lessons,_periods = periods,_days = days;
|
||||
factory _WidgetTimetableData.fromJson(Map<String, dynamic> json) => _$WidgetTimetableDataFromJson(json);
|
||||
|
||||
@override final DateTime fetchedAt;
|
||||
@@ -822,6 +1093,15 @@ class _WidgetTimetableData implements WidgetTimetableData {
|
||||
|
||||
@override@JsonKey() final bool isHoliday;
|
||||
@override final String? holidayName;
|
||||
/// Week payload only: one entry per day of the covered window.
|
||||
final List<WidgetDayInfo> _days;
|
||||
/// Week payload only: one entry per day of the covered window.
|
||||
@override@JsonKey() List<WidgetDayInfo> get days {
|
||||
if (_days is EqualUnmodifiableListView) return _days;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_days);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of WidgetTimetableData
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -836,16 +1116,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other._days, _days));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName);
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)';
|
||||
}
|
||||
|
||||
|
||||
@@ -856,7 +1136,7 @@ abstract mixin class _$WidgetTimetableDataCopyWith<$Res> implements $WidgetTimet
|
||||
factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName
|
||||
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days
|
||||
});
|
||||
|
||||
|
||||
@@ -873,7 +1153,7 @@ class __$WidgetTimetableDataCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of WidgetTimetableData
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) {
|
||||
return _then(_WidgetTimetableData(
|
||||
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable
|
||||
@@ -881,7 +1161,8 @@ as DateTime,lessons: null == lessons ? _self._lessons : lessons // ignore: cast_
|
||||
as List<WidgetLesson>,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable
|
||||
as List<WidgetPeriod>,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
|
||||
as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
as String?,days: null == days ? _self._days : days // ignore: cast_nullable_to_non_nullable
|
||||
as List<WidgetDayInfo>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,20 @@ Map<String, dynamic> _$WidgetPeriodToJson(_WidgetPeriod instance) =>
|
||||
'virtualEndMinutes': instance.virtualEndMinutes,
|
||||
};
|
||||
|
||||
_WidgetDayInfo _$WidgetDayInfoFromJson(Map<String, dynamic> json) =>
|
||||
_WidgetDayInfo(
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
isHoliday: json['isHoliday'] as bool? ?? false,
|
||||
holidayName: json['holidayName'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$WidgetDayInfoToJson(_WidgetDayInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'date': instance.date.toIso8601String(),
|
||||
'isHoliday': instance.isHoliday,
|
||||
'holidayName': instance.holidayName,
|
||||
};
|
||||
|
||||
_WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
|
||||
_WidgetTimetableData(
|
||||
fetchedAt: DateTime.parse(json['fetchedAt'] as String),
|
||||
@@ -77,6 +91,11 @@ _WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
|
||||
const <WidgetPeriod>[],
|
||||
isHoliday: json['isHoliday'] as bool? ?? false,
|
||||
holidayName: json['holidayName'] as String?,
|
||||
days:
|
||||
(json['days'] as List<dynamic>?)
|
||||
?.map((e) => WidgetDayInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <WidgetDayInfo>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$WidgetTimetableDataToJson(
|
||||
@@ -88,4 +107,5 @@ Map<String, dynamic> _$WidgetTimetableDataToJson(
|
||||
'periods': instance.periods,
|
||||
'isHoliday': instance.isHoliday,
|
||||
'holidayName': instance.holidayName,
|
||||
'days': instance.days,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_time
|
||||
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||
import '../extensions/date_time.dart';
|
||||
import '../view/pages/timetable/data/lesson_labels.dart';
|
||||
import '../view/pages/timetable/data/lesson_merger.dart';
|
||||
import '../view/pages/timetable/data/lesson_period_schedule.dart';
|
||||
import '../view/pages/timetable/data/lesson_status.dart';
|
||||
@@ -34,12 +36,20 @@ class WidgetDataMapper {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
static DateTime resolveWeekAnchor(DateTime now) {
|
||||
final anchor = resolveDayAnchor(now);
|
||||
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
|
||||
static DateTime resolveWeekAnchor(DateTime now) =>
|
||||
startOfCalendarWeek(resolveDayAnchor(now));
|
||||
|
||||
/// Monday of the calendar week containing [reference] — no roll-forward,
|
||||
/// unlike [resolveWeekAnchor]. Start of the week payload's 14-day window.
|
||||
static DateTime startOfCalendarWeek(DateTime reference) {
|
||||
final monday = reference.subtract(Duration(days: reference.weekday - 1));
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
|
||||
/// Days covered by the week payload: current calendar week + the next, so
|
||||
/// native renderers can roll the view forward without fresh data.
|
||||
static const int weekWindowDays = 14;
|
||||
|
||||
static WidgetTimetableData buildDayData({
|
||||
required DateTime now,
|
||||
required Iterable<McTimetableEntry> lessons,
|
||||
@@ -49,6 +59,7 @@ class WidgetDataMapper {
|
||||
TimetableGetTimegridResponse? timegrid,
|
||||
GetCustomTimetableEventResponse? customEvents,
|
||||
bool connectDoubleLessons = true,
|
||||
bool showClassInsteadOfTeacher = false,
|
||||
}) {
|
||||
final anchor = resolveDayAnchor(now);
|
||||
final holiday = _findHoliday(anchor, holidays);
|
||||
@@ -59,7 +70,13 @@ class WidgetDataMapper {
|
||||
? LessonMerger.merge(dayLessons)
|
||||
: dayLessons;
|
||||
final mapped = <WidgetLesson>[
|
||||
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
|
||||
..._mapAll(
|
||||
source,
|
||||
now,
|
||||
subjects,
|
||||
rooms,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
),
|
||||
..._expandCustomEvents(customEvents, dayStart, dayEnd),
|
||||
]..sort((a, b) => a.start.compareTo(b.start));
|
||||
return WidgetTimetableData(
|
||||
@@ -81,12 +98,17 @@ class WidgetDataMapper {
|
||||
TimetableGetTimegridResponse? timegrid,
|
||||
GetCustomTimetableEventResponse? customEvents,
|
||||
bool connectDoubleLessons = true,
|
||||
bool showClassInsteadOfTeacher = false,
|
||||
}) {
|
||||
final anchor = resolveWeekAnchor(now);
|
||||
final endExclusive = anchor.add(const Duration(days: 5));
|
||||
// The window is anchored at the *current* calendar week, not the
|
||||
// (possibly rolled-forward) week anchor: on Friday evening the payload
|
||||
// must still contain today for renderers that derive day slices.
|
||||
final windowStart = startOfCalendarWeek(now);
|
||||
final endExclusive = windowStart.add(const Duration(days: weekWindowDays));
|
||||
final weekLessons = lessons.where((l) {
|
||||
final dt = l.startDateTime;
|
||||
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
|
||||
return !dt.isBefore(windowStart) && dt.isBefore(endExclusive);
|
||||
}).toList();
|
||||
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
|
||||
// a 1st-period lesson on Tue if subject/teacher match.
|
||||
@@ -94,14 +116,45 @@ class WidgetDataMapper {
|
||||
? _mergePerDay(weekLessons)
|
||||
: weekLessons;
|
||||
final mapped = <WidgetLesson>[
|
||||
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
|
||||
..._expandCustomEvents(customEvents, anchor, endExclusive),
|
||||
..._mapAll(
|
||||
source,
|
||||
now,
|
||||
subjects,
|
||||
rooms,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
),
|
||||
..._expandCustomEvents(customEvents, windowStart, endExclusive),
|
||||
]..sort((a, b) => a.start.compareTo(b.start));
|
||||
final days = [
|
||||
for (var i = 0; i < weekWindowDays; i++)
|
||||
_dayInfo(windowStart.addDays(i), holidays),
|
||||
];
|
||||
// The anchor always lies inside the window; the orElse only guards the
|
||||
// impossible.
|
||||
final anchorInfo = days.firstWhere(
|
||||
(d) => d.date == anchor,
|
||||
orElse: () => _dayInfo(anchor, holidays),
|
||||
);
|
||||
return WidgetTimetableData(
|
||||
fetchedAt: now,
|
||||
anchorDate: anchor,
|
||||
lessons: _resolveCollisions(mapped),
|
||||
periods: _resolvePeriods(timegrid),
|
||||
isHoliday: anchorInfo.isHoliday,
|
||||
holidayName: anchorInfo.holidayName,
|
||||
days: days,
|
||||
);
|
||||
}
|
||||
|
||||
static WidgetDayInfo _dayInfo(
|
||||
DateTime day,
|
||||
TimetableGetHolidaysResponse? holidays,
|
||||
) {
|
||||
final holiday = _findHoliday(day, holidays);
|
||||
return WidgetDayInfo(
|
||||
date: day,
|
||||
isHoliday: holiday != null,
|
||||
holidayName: holiday?.longName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,12 +297,29 @@ class WidgetDataMapper {
|
||||
return [for (final group in byDay.values) ...LessonMerger.merge(group)];
|
||||
}
|
||||
|
||||
static Iterable<WidgetLesson> _mapAll(
|
||||
Iterable<McTimetableEntry> source,
|
||||
DateTime now,
|
||||
TimetableGetSubjectsResponse? subjects,
|
||||
TimetableGetRoomsResponse? rooms, {
|
||||
required bool showClassInsteadOfTeacher,
|
||||
}) => source.map(
|
||||
(l) => _mapLesson(
|
||||
l,
|
||||
now,
|
||||
subjects,
|
||||
rooms,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
),
|
||||
);
|
||||
|
||||
static WidgetLesson _mapLesson(
|
||||
McTimetableEntry lesson,
|
||||
DateTime now,
|
||||
TimetableGetSubjectsResponse? subjects,
|
||||
TimetableGetRoomsResponse? rooms,
|
||||
) {
|
||||
TimetableGetRoomsResponse? rooms, {
|
||||
bool showClassInsteadOfTeacher = false,
|
||||
}) {
|
||||
final start = lesson.startDateTime;
|
||||
final end = lesson.endDateTime;
|
||||
final status = _mapStatus(
|
||||
@@ -276,8 +346,14 @@ class WidgetDataMapper {
|
||||
roomName;
|
||||
}
|
||||
final teacher = lesson.teachers.firstOrNull;
|
||||
final teacherName = teacher?.shortName;
|
||||
final originalTeacher = teacher?.originalShortName;
|
||||
// Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
|
||||
// Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten
|
||||
// den Lehrer als Fallback.
|
||||
final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null;
|
||||
final teacherName = classLabel ?? teacher?.shortName;
|
||||
final originalTeacher = classLabel != null
|
||||
? null
|
||||
: teacher?.originalShortName;
|
||||
return WidgetLesson(
|
||||
start: start,
|
||||
end: end,
|
||||
|
||||
@@ -22,14 +22,18 @@ class WidgetPublisher {
|
||||
static Future<void> publishFromBlocState(
|
||||
TimetableState state, {
|
||||
Settings? settings,
|
||||
bool isTeacher = false,
|
||||
}) async {
|
||||
try {
|
||||
final connectDouble =
|
||||
settings?.timetableSettings.connectDoubleLessons ?? true;
|
||||
// Mirror into widget storage so the background isolate sees the same
|
||||
// value the user just toggled.
|
||||
await WidgetSync.setConnectDoubleLessons(connectDouble);
|
||||
await WidgetSync.setThemeMode(_themeName(settings?.appTheme));
|
||||
// values the user just toggled — concurrently, they are independent.
|
||||
await Future.wait([
|
||||
WidgetSync.setConnectDoubleLessons(connectDouble),
|
||||
WidgetSync.setThemeMode(_themeName(settings?.appTheme)),
|
||||
WidgetSync.setIsTeacher(isTeacher),
|
||||
]);
|
||||
final lessons = state.getAllKnownLessons();
|
||||
final now = widgetNow();
|
||||
final dayData = WidgetDataMapper.buildDayData(
|
||||
@@ -41,6 +45,7 @@ class WidgetPublisher {
|
||||
timegrid: state.timegrid,
|
||||
customEvents: state.customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
final weekData = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -51,6 +56,7 @@ class WidgetPublisher {
|
||||
timegrid: state.timegrid,
|
||||
customEvents: state.customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
await WidgetSync.writeDayData(dayData);
|
||||
await WidgetSync.writeWeekData(weekData);
|
||||
|
||||
@@ -12,14 +12,18 @@ class WidgetSync {
|
||||
static const String iosAppGroupId =
|
||||
'group.eu.mhsl.marianum.mobile.client.widget';
|
||||
|
||||
static const String iosWidgetKind = 'TimetableWidget';
|
||||
// Must match the WidgetKit `kind` strings declared in
|
||||
// TimetableWidgetExtension.swift — a mismatch makes reloadTimelines a no-op.
|
||||
static const String iosDayWidgetKind = 'TimetableDayWidget';
|
||||
static const String iosWeekWidgetKind = 'TimetableWeekWidget';
|
||||
static const String androidDayProvider = 'TimetableDayWidget';
|
||||
static const String androidWeekProvider = 'TimetableWeekWidget';
|
||||
|
||||
// `_v1` suffix lets a future schema change invalidate stale snapshots
|
||||
// by bumping the key instead of risking a parse crash.
|
||||
// Version suffix lets a schema change invalidate stale snapshots by
|
||||
// bumping the key instead of risking a parse crash.
|
||||
static const String dayDataKey = 'widget_data_day_v1';
|
||||
static const String weekDataKey = 'widget_data_week_v1';
|
||||
// v2: 14-day window + per-day `days` holiday info.
|
||||
static const String weekDataKey = 'widget_data_week_v2';
|
||||
static const String fetchedAtKey = 'widget_data_fetched_at_v1';
|
||||
static const String loggedInKey = 'widget_data_logged_in_v1';
|
||||
// Mirrored into widget storage so the background isolate can read it
|
||||
@@ -27,6 +31,9 @@ class WidgetSync {
|
||||
static const String connectDoubleLessonsKey =
|
||||
'widget_setting_connect_double_lessons_v1';
|
||||
static const String themeModeKey = 'widget_setting_theme_mode_v1';
|
||||
// Mirrored from CapabilitiesCubit so the background isolate can render
|
||||
// teacher plans (class instead of teacher name) without bloc storage.
|
||||
static const String isTeacherKey = 'widget_setting_is_teacher_v1';
|
||||
// Mirrored so the background isolate hits the same Marianum-Connect base
|
||||
// URL the in-app settings cubit currently has selected.
|
||||
static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1';
|
||||
@@ -54,25 +61,30 @@ class WidgetSync {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> setLoggedIn(bool loggedIn) async {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<bool>(loggedInKey, loggedIn);
|
||||
}
|
||||
static Future<void> setLoggedIn(bool loggedIn) =>
|
||||
_setBool(loggedInKey, loggedIn);
|
||||
|
||||
static Future<void> setConnectDoubleLessons(bool value) async {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<bool>(connectDoubleLessonsKey, value);
|
||||
}
|
||||
static Future<void> setConnectDoubleLessons(bool value) =>
|
||||
_setBool(connectDoubleLessonsKey, value);
|
||||
|
||||
/// Default `true` matches `default_settings.dart` — fresh install behaves
|
||||
/// like the in-app calendar.
|
||||
static Future<bool> getConnectDoubleLessons() async {
|
||||
static Future<bool> getConnectDoubleLessons() =>
|
||||
_getBool(connectDoubleLessonsKey, defaultValue: true);
|
||||
|
||||
static Future<void> setIsTeacher(bool value) => _setBool(isTeacherKey, value);
|
||||
|
||||
static Future<bool> getIsTeacher() =>
|
||||
_getBool(isTeacherKey, defaultValue: false);
|
||||
|
||||
static Future<void> _setBool(String key, bool value) async {
|
||||
await ensureInitialized();
|
||||
final value = await HomeWidget.getWidgetData<bool>(
|
||||
connectDoubleLessonsKey,
|
||||
defaultValue: true,
|
||||
);
|
||||
return value ?? true;
|
||||
await HomeWidget.saveWidgetData<bool>(key, value);
|
||||
}
|
||||
|
||||
static Future<bool> _getBool(String key, {required bool defaultValue}) async {
|
||||
await ensureInitialized();
|
||||
return await HomeWidget.getWidgetData<bool>(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> setThemeMode(String mode) async {
|
||||
@@ -90,6 +102,12 @@ class WidgetSync {
|
||||
return HomeWidget.getWidgetData<String>(marianumConnectBaseUrlKey);
|
||||
}
|
||||
|
||||
static Future<DateTime?> getFetchedAt() async {
|
||||
await ensureInitialized();
|
||||
final raw = await HomeWidget.getWidgetData<String>(fetchedAtKey);
|
||||
return raw == null ? null : DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<String>(dayDataKey, null);
|
||||
@@ -103,11 +121,11 @@ class WidgetSync {
|
||||
try {
|
||||
await HomeWidget.updateWidget(
|
||||
androidName: androidDayProvider,
|
||||
iOSName: iosWidgetKind,
|
||||
iOSName: iosDayWidgetKind,
|
||||
);
|
||||
await HomeWidget.updateWidget(
|
||||
androidName: androidWeekProvider,
|
||||
iOSName: iosWidgetKind,
|
||||
iOSName: iosWeekWidgetKind,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
log('WidgetSync.triggerUpdate failed: $e');
|
||||
|
||||
+3
-5
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.0+58
|
||||
version: 1.5.2+62
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0"
|
||||
|
||||
@@ -99,15 +99,13 @@ dependencies:
|
||||
app_settings: ^7.0.0
|
||||
flutter_layout_grid: ^2.0.8
|
||||
flutter_markdown_plus: ^1.0.12
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
# Screenshot-Automatisierung für den Play Store: treibt den Demo-Login über
|
||||
# flutter drive und ruft binding.takeScreenshot pro Hauptscreen auf
|
||||
# (integration_test/ + test_driver/, orchestriert von tool/screenshots.sh).
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
fake_async: ^1.3.1
|
||||
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/login_flow/login_flow_api.dart';
|
||||
|
||||
void main() {
|
||||
group('LoginFlowInit.fromJson', () {
|
||||
test('parses a complete init response', () {
|
||||
final init = LoginFlowInit.fromJson({
|
||||
'poll': {
|
||||
'token': 'abc123',
|
||||
'endpoint': 'https://cloud.example.org/login/v2/poll',
|
||||
},
|
||||
'login': 'https://cloud.example.org/login/v2/flow/xyz',
|
||||
});
|
||||
expect(init.loginUrl, 'https://cloud.example.org/login/v2/flow/xyz');
|
||||
expect(init.pollToken, 'abc123');
|
||||
expect(init.pollEndpoint, 'https://cloud.example.org/login/v2/poll');
|
||||
});
|
||||
|
||||
test('throws on missing login url', () {
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({
|
||||
'poll': {'token': 't', 'endpoint': 'e'},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on missing poll token or endpoint', () {
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({
|
||||
'poll': {'endpoint': 'e'},
|
||||
'login': 'l',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({
|
||||
'poll': {'token': 't'},
|
||||
'login': 'l',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({'login': 'l'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginFlowCredentials.fromJson', () {
|
||||
test('parses a complete poll response', () {
|
||||
final credentials = LoginFlowCredentials.fromJson({
|
||||
'server': 'https://cloud.example.org',
|
||||
'loginName': 'jdoe',
|
||||
'appPassword': 'secret-app-password',
|
||||
});
|
||||
expect(credentials.server, 'https://cloud.example.org');
|
||||
expect(credentials.loginName, 'jdoe');
|
||||
expect(credentials.appPassword, 'secret-app-password');
|
||||
});
|
||||
|
||||
test('tolerates a missing server field', () {
|
||||
final credentials = LoginFlowCredentials.fromJson({
|
||||
'loginName': 'jdoe',
|
||||
'appPassword': 'secret',
|
||||
});
|
||||
expect(credentials.server, '');
|
||||
});
|
||||
|
||||
test('throws on missing loginName or appPassword', () {
|
||||
expect(
|
||||
() => LoginFlowCredentials.fromJson({'appPassword': 'secret'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowCredentials.fromJson({'loginName': 'jdoe'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowCredentials.fromJson({
|
||||
'loginName': 'jdoe',
|
||||
'appPassword': '',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginFlowApi.loginNameMatches', () {
|
||||
test('matches case-insensitively and ignores surrounding whitespace', () {
|
||||
expect(
|
||||
LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: 'JDoe'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: ' jdoe '),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a different account', () {
|
||||
expect(
|
||||
LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: 'other'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/background/widget_background_task.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime(2026, 8, 6, 12, 0);
|
||||
|
||||
group('shouldSkipRefresh', () {
|
||||
test('skips when the snapshot is younger than the debounce window', () {
|
||||
expect(
|
||||
shouldSkipRefresh(
|
||||
fetchedAt: now.subtract(const Duration(minutes: 5)),
|
||||
now: now,
|
||||
force: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshes when the snapshot is older than the debounce window', () {
|
||||
expect(
|
||||
shouldSkipRefresh(
|
||||
fetchedAt: now.subtract(const Duration(minutes: 15)),
|
||||
now: now,
|
||||
force: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshes exactly at the debounce boundary', () {
|
||||
expect(
|
||||
shouldSkipRefresh(
|
||||
fetchedAt: now.subtract(WidgetBackgroundTask.refreshDebounce),
|
||||
now: now,
|
||||
force: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('force bypasses a fresh snapshot', () {
|
||||
expect(
|
||||
shouldSkipRefresh(
|
||||
fetchedAt: now.subtract(const Duration(minutes: 1)),
|
||||
now: now,
|
||||
force: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshes when no snapshot exists yet', () {
|
||||
expect(shouldSkipRefresh(fetchedAt: null, now: now, force: false), isFalse);
|
||||
});
|
||||
|
||||
test('refreshes when fetchedAt lies in the future (clock change)', () {
|
||||
expect(
|
||||
shouldSkipRefresh(
|
||||
fetchedAt: now.add(const Duration(minutes: 5)),
|
||||
now: now,
|
||||
force: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -53,6 +53,45 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('registrationTypesFor', () {
|
||||
test('password accounts maintain both registrations', () {
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: false,
|
||||
hasTalkAppPassword: false,
|
||||
),
|
||||
PushRegistrationType.values,
|
||||
);
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: false,
|
||||
hasTalkAppPassword: true,
|
||||
),
|
||||
PushRegistrationType.values,
|
||||
);
|
||||
});
|
||||
|
||||
test('flow account without talk app password is talk-only', () {
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: true,
|
||||
hasTalkAppPassword: false,
|
||||
),
|
||||
[PushRegistrationType.talk],
|
||||
);
|
||||
});
|
||||
|
||||
test('flow account with second (talk) app password maintains both', () {
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: true,
|
||||
hasTalkAppPassword: true,
|
||||
),
|
||||
PushRegistrationType.values,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('pushTokenVariant', () {
|
||||
test('general uses the raw token, talk appends the suffix', () {
|
||||
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
|
||||
|
||||
@@ -17,6 +17,47 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('widget refresh push identified by source + type', () {
|
||||
expect(
|
||||
classifyPush({'source': 'connect', 'type': 'widget-refresh'}),
|
||||
PushKind.widgetRefresh,
|
||||
);
|
||||
});
|
||||
|
||||
test('widget refresh push with reason still classifies', () {
|
||||
expect(
|
||||
classifyPush({
|
||||
'source': 'connect',
|
||||
'type': 'widget-refresh',
|
||||
'reason': 'morning',
|
||||
}),
|
||||
PushKind.widgetRefresh,
|
||||
);
|
||||
});
|
||||
|
||||
test('connect push with unrelated type stays connect', () {
|
||||
expect(
|
||||
classifyPush({'source': 'connect', 'type': 'newsletter', 'title': 'Hi'}),
|
||||
PushKind.connect,
|
||||
);
|
||||
});
|
||||
|
||||
test('nextcloud fields take precedence over widget-refresh type', () {
|
||||
expect(
|
||||
classifyPush({
|
||||
'subject': 'enc',
|
||||
'signature': 'sig',
|
||||
'source': 'connect',
|
||||
'type': 'widget-refresh',
|
||||
}),
|
||||
PushKind.nextcloud,
|
||||
);
|
||||
});
|
||||
|
||||
test('widget-refresh type without connect source is unknown', () {
|
||||
expect(classifyPush({'type': 'widget-refresh'}), PushKind.unknown);
|
||||
});
|
||||
|
||||
test('subject without signature is not a nextcloud push', () {
|
||||
expect(classifyPush({'subject': 'enc'}), PushKind.unknown);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import 'package:marianum_mobile/storage/timetable_settings.dart';
|
||||
import 'package:marianum_mobile/view/pages/timetable/data/lesson_merger.dart';
|
||||
import 'package:marianum_mobile/view/pages/timetable/data/timetable_appointment_factory.dart';
|
||||
import 'package:marianum_mobile/view/pages/timetable/data/timetable_name_mode.dart';
|
||||
|
||||
McTimetableEntry _lesson({
|
||||
int id = 1,
|
||||
int hour = 8,
|
||||
int minute = 0,
|
||||
List<String> classNames = const ['7a'],
|
||||
List<String> subjects = const ['M'],
|
||||
List<String> rooms = const ['A101'],
|
||||
}) => McTimetableEntry(
|
||||
id: id,
|
||||
date: DateTime(2026, 5, 4),
|
||||
startTime: DateTime(1970, 1, 1, hour, minute),
|
||||
endTime: DateTime(1970, 1, 1, hour, minute + 45),
|
||||
subjects: subjects,
|
||||
teachers: [McTimetableTeacher(shortName: 'MUE', displayName: 'Stefan Müller')],
|
||||
rooms: rooms,
|
||||
classNames: classNames,
|
||||
lessonType: 'LESSON',
|
||||
status: 'REGULAR',
|
||||
substitutionText: null,
|
||||
lessonText: null,
|
||||
infoText: null,
|
||||
);
|
||||
|
||||
final _settings = TimetableSettings(
|
||||
connectDoubleLessons: false,
|
||||
timetableNameMode: TimetableNameMode.name,
|
||||
);
|
||||
|
||||
String _location({
|
||||
required bool showClassInsteadOfTeacher,
|
||||
List<String> classNames = const ['7a'],
|
||||
}) => TimetableAppointmentFactory(
|
||||
lessons: [_lesson(classNames: classNames)],
|
||||
customEvents: const [],
|
||||
subjects: const [],
|
||||
settings: _settings,
|
||||
now: DateTime(2026, 5, 4),
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
).build().single.location!;
|
||||
|
||||
void main() {
|
||||
group('teacher plan tile label', () {
|
||||
test('shows the teacher surname by default', () {
|
||||
expect(_location(showClassInsteadOfTeacher: false), 'A101\nMüller');
|
||||
});
|
||||
|
||||
test('shows the class instead of the teacher on teacher plans', () {
|
||||
expect(_location(showClassInsteadOfTeacher: true), 'A101\n7a');
|
||||
});
|
||||
|
||||
test('joins multiple classes', () {
|
||||
expect(
|
||||
_location(
|
||||
showClassInsteadOfTeacher: true,
|
||||
classNames: const ['7a', '7b'],
|
||||
),
|
||||
'A101\n7a, 7b',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the teacher when the entry has no class', () {
|
||||
expect(
|
||||
_location(showClassInsteadOfTeacher: true, classNames: const []),
|
||||
'A101\nMüller',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('lesson merger class separation', () {
|
||||
test('does not merge back-to-back lessons of different classes', () {
|
||||
final merged = LessonMerger.merge([
|
||||
_lesson(id: 1, hour: 8, classNames: const ['7a']),
|
||||
_lesson(id: 2, hour: 8, minute: 45, classNames: const ['7b']),
|
||||
]);
|
||||
expect(merged, hasLength(2));
|
||||
});
|
||||
|
||||
test('still merges back-to-back lessons of the same class', () {
|
||||
final merged = LessonMerger.merge([
|
||||
_lesson(id: 1, hour: 8),
|
||||
_lesson(id: 2, hour: 8, minute: 45),
|
||||
]);
|
||||
expect(merged, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -81,7 +81,22 @@ void main() {
|
||||
testWidgets('tapping a toolbar button switches the shared mode', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_host(_tableDoc()));
|
||||
// The mode toolbar only renders once the table overflows its width — at the
|
||||
// full test surface the fixture table fits and the toggle stays hidden. Pin
|
||||
// a narrow width so it overflows, then let the post-frame overflow
|
||||
// measurement settle so the toolbar is present before tapping.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: SizedBox(width: 200, child: PmDocumentView(doc: _tableDoc())),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(pmTableMode.value, PmTableMode.scroll);
|
||||
|
||||
await tester.tap(find.byTooltip('Spalten umbrechen').first);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/route_transition_gate.dart';
|
||||
|
||||
void main() {
|
||||
const gated = Key('gated-child');
|
||||
const placeholder = Key('gate-placeholder');
|
||||
|
||||
Widget gatedPage() => Scaffold(
|
||||
body: RouteTransitionGate(
|
||||
placeholder: const SizedBox(key: placeholder),
|
||||
builder: (_) => const SizedBox(key: gated),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> pumpApp(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Builder(builder: (_) => const Scaffold())),
|
||||
);
|
||||
}
|
||||
|
||||
NavigatorState navigator(WidgetTester tester) =>
|
||||
tester.state<NavigatorState>(find.byType(Navigator));
|
||||
|
||||
testWidgets('shows placeholder during push, child once settled', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.byKey(placeholder), findsOneWidget);
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
navigator(tester).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
expect(find.byKey(placeholder), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('gates during a secondary transition and recovers after pop', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
// Mid secondary transition the fragile subtree must be unmounted.
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
navigator(tester).pop();
|
||||
await tester.pumpAndSettle();
|
||||
// Back at rest on the gated route: child is mounted again.
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -345,15 +345,32 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('startOfCalendarWeek', () {
|
||||
test('Tuesday maps to its own Monday', () {
|
||||
expect(
|
||||
WidgetDataMapper.startOfCalendarWeek(DateTime(2026, 5, 5, 10)),
|
||||
DateTime(2026, 5, 4),
|
||||
);
|
||||
});
|
||||
|
||||
test('Sunday stays in the current week (no roll-forward)', () {
|
||||
expect(
|
||||
WidgetDataMapper.startOfCalendarWeek(DateTime(2026, 5, 10, 22)),
|
||||
DateTime(2026, 5, 4),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildWeekData', () {
|
||||
final now = DateTime(2026, 5, 5, 10); // Tuesday
|
||||
|
||||
test('contains lessons across the school week', () {
|
||||
test('covers the 14-day window including next week', () {
|
||||
final lessons = [
|
||||
_lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'),
|
||||
_lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'),
|
||||
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
|
||||
_lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
|
||||
_lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'),
|
||||
];
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -365,8 +382,82 @@ void main() {
|
||||
expect(data.anchorDate, DateTime(2026, 5, 4));
|
||||
expect(
|
||||
data.lessons.map((l) => l.subjectShort).toList(),
|
||||
['MO', 'WE', 'FR'],
|
||||
['MO', 'WE', 'FR', 'NEXT'],
|
||||
);
|
||||
});
|
||||
|
||||
test('window stays on the current week when the anchor rolls forward', () {
|
||||
// Friday evening: day/week anchors jump to next Monday, but the window
|
||||
// still starts at the current week's Monday so today stays available.
|
||||
final fridayEvening = DateTime(2026, 5, 8, 18);
|
||||
final lessons = [
|
||||
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
|
||||
_lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
|
||||
];
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: fridayEvening,
|
||||
lessons: lessons,
|
||||
subjects: null,
|
||||
rooms: null,
|
||||
holidays: null,
|
||||
);
|
||||
expect(data.anchorDate, DateTime(2026, 5, 11));
|
||||
expect(
|
||||
data.lessons.map((l) => l.subjectShort).toList(),
|
||||
['FR', 'NEXT'],
|
||||
);
|
||||
});
|
||||
|
||||
test('carries per-day holiday info in days', () {
|
||||
final holidays = TimetableGetHolidaysResponse(
|
||||
result: [
|
||||
McHoliday(
|
||||
shortName: 'Pfingsten',
|
||||
longName: 'Pfingstferien',
|
||||
startDate: DateTime(2026, 5, 14),
|
||||
endDate: DateTime(2026, 5, 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
lessons: const [],
|
||||
subjects: null,
|
||||
rooms: null,
|
||||
holidays: holidays,
|
||||
);
|
||||
expect(data.days, hasLength(WidgetDataMapper.weekWindowDays));
|
||||
expect(data.days.first.date, DateTime(2026, 5, 4));
|
||||
expect(data.days.last.date, DateTime(2026, 5, 17));
|
||||
final holidayDays =
|
||||
data.days.where((d) => d.isHoliday).map((d) => d.date).toList();
|
||||
expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]);
|
||||
expect(
|
||||
data.days.firstWhere((d) => d.isHoliday).holidayName,
|
||||
'Pfingstferien',
|
||||
);
|
||||
});
|
||||
|
||||
test('sets top-level holiday flags for the anchor day', () {
|
||||
final holidays = TimetableGetHolidaysResponse(
|
||||
result: [
|
||||
McHoliday(
|
||||
shortName: 'Oster',
|
||||
longName: 'Osterferien',
|
||||
startDate: DateTime(2026, 5, 4),
|
||||
endDate: DateTime(2026, 5, 8),
|
||||
),
|
||||
],
|
||||
);
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
lessons: const [],
|
||||
subjects: null,
|
||||
rooms: null,
|
||||
holidays: holidays,
|
||||
);
|
||||
expect(data.isHoliday, isTrue);
|
||||
expect(data.holidayName, 'Osterferien');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user