22 Commits

Author SHA1 Message Date
MineTec ccb22a497d added support for 2fa login with browser flow 2026-08-10 20:00:38 +02:00
MineTec 889d8f67c5 show classname instead of teacher name in teacher timetable view 2026-08-09 12:19:57 +02:00
MineTec 39c16bd4ea enhanced route transition gate because of a syncfusion viewer bug 2026-08-08 22:13:19 +02:00
MineTec b9cb1df473 added cache folder sweep check before file actions, preventing errors 2026-08-07 21:36:09 +02:00
MineTec 62fa337188 added integration_test to deps 2026-08-07 21:25:08 +02:00
MineTec c0dadf8b6e bumped minor version to 1.5.1+61 2026-08-06 20:30:20 +02:00
MineTec ab23422a86 widget refresh enhancements 2026-08-06 20:22:32 +02:00
MineTec 646e2c0451 removed timetable limits, fully dependent on marianumConnect now 2026-08-05 20:03:02 +02:00
MineTec 246cb0f527 simplified loadable state logic by removing connectivity debouncing and inlining indicator helpers; updated error bar visibility and chat list polling behavior 2026-08-02 08:21:25 +02:00
MineTec 4c2e9b47e7 Merge remote-tracking branch 'origin/develop' into develop 2026-08-01 18:19:56 +02:00
MineTec 2d690736e3 implemented connectivity debouncing and refetch throttling for loadable state UI components 2026-08-01 18:16:22 +02:00
MineTec 778c473631 added telemetry call on app resume with 15 minute cooldown 2026-07-28 12:52:07 +02:00
MineTec 7e9cbcf1e9 fixed widget mounting errors 2026-07-28 12:51:39 +02:00
MineTec 32be67426c Merge remote-tracking branch 'origin/develop' into develop 2026-07-26 13:49:05 +02:00
MineTec b957189fd3 implemented absence report module with form validation, prefill support, and API integration 2026-07-26 11:45:48 +02:00
MineTec 624c5512a6 fixed failing test 2026-07-23 14:22:32 +02:00
MineTec 3493cf8617 fixed error on pdf viewer transition 2026-07-23 14:16:33 +02:00
MineTec 75080a2c49 implemented shared items view for Talk chats with tabbed categories and on-scroll pagination 2026-07-19 10:58:02 +02:00
MineTec f3cd7896d9 implemented backward pagination for chat history with on-scroll prefetching 2026-07-17 20:14:47 +02:00
Marianum 101e7c909c bump version to 1.4.0+59 2026-07-17 15:19:26 +02:00
Marianum 429f1e6f96 fix build against updated dependencies: re-add DioExceptionType.transformTimeout case (restored in dio 5.10.0) and rename SwiftReceiveSharingIntentPlugin to ReceiveSharingIntentPlugin (renamed in receive_sharing_intent 1.9.0); refresh Podfile.lock and SPM Package.resolved 2026-07-17 15:19:10 +02:00
Marianum a0c55a811c completed iOS push notification setup: created the NotificationServiceExtension Xcode target programmatically (via xcodeproj), embedded it in Runner, and added its xcconfig base configs; fixed Automatic Signing by switching the NSE↔Runner shared keychain from an app-group to a team-prefixed access group across entitlements, Dart and Swift; fixed the analyzer break from dio 5.9.2 dropping DioExceptionType.transformTimeout 2026-07-17 15:05:34 +02:00
99 changed files with 4465 additions and 425 deletions
+4
View File
@@ -65,5 +65,9 @@ flutter {
dependencies { dependencies {
implementation 'com.android.support:multidex:2.0.1' 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' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
} }
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer 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>`. * Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
*/ */
class TimetableDayWidget : AppWidgetProvider() { 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( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
class TimetableWeekWidget : AppWidgetProvider() { 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( override fun onUpdate(
context: Context, context: Context,
appWidgetManager: AppWidgetManager, appWidgetManager: AppWidgetManager,
@@ -33,6 +33,8 @@ data class WidgetLesson(
val subjectShort: String, val subjectShort: String,
val subjectLong: String?, val subjectLong: String?,
val room: 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 teacher: String?,
val originalTeacher: String?, val originalTeacher: String?,
val status: WidgetLessonStatus, val status: WidgetLessonStatus,
@@ -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)
}
}
@@ -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_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_LOGGED_IN = "widget_data_logged_in_v1"
const val KEY_THEME_MODE = "widget_setting_theme_mode_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 /// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this /// and the `keychain-access-groups` entitlement of BOTH the Runner and this
/// extension. Wrong value here => keychain reads return nil => placeholder. /// extension. Wrong value here => keychain reads return nil => placeholder.
private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget" private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private static let devicePrivateKeyAccount = "push_device_private_key_pem" private static let devicePrivateKeyAccount = "push_device_private_key_pem"
private static let serverPublicKeyAccount = "push_server_public_key_pem" private static let serverPublicKeyAccount = "push_server_public_key_pem"
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
/// kSecClass = kSecClassGenericPassword /// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim /// kSecAttrAccount = the Dart key, verbatim
/// kSecAttrService = (unset the Dart IOSOptions set no accountName) /// kSecAttrService = (unset the Dart IOSOptions set no accountName)
/// kSecAttrAccessGroup = the App Group id /// kSecAttrAccessGroup = the team-prefixed shared keychain group
/// value = raw UTF-8 bytes of the string /// value = raw UTF-8 bytes of the string
private func keychainString(_ account: String) -> String? { private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [ let query: [CFString: Any] = [
@@ -8,7 +8,7 @@
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+36 -10
View File
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung | | `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped | | `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` | | `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) | | **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an | | `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen > **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene > schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
@@ -67,6 +68,15 @@ iOS zeigt die fertige Notification
## 3. Xcode-Checkliste (auf dem Mac) ## 3. Xcode-Checkliste (auf dem Mac)
> **Stand 2026-07-07:** Abschnitte 3.13.2 (Target anlegen, Dateien zuordnen) sind
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.33.4, 3.6)** —
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
> aufgesetzt werden muss).
### 3.1 Target anlegen ### 3.1 Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`). 1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**. 2. **File → New → Target… → iOS → Notification Service Extension**.
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
## 4. Ermittelte Keychain-Details (verbindlich) ## 4. Ermittelte Keychain-Details (verbindlich)
Die Dart-Seite schreibt mit Die Dart-Seite schreibt mit
`IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`. `IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
> Automatic Signing mit „doesn't match the entitlements file's value for the
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain: `pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
@@ -141,7 +162,7 @@ Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
| `kSecClass` | `kSecClassGenericPassword` | | `kSecClass` | `kSecClassGenericPassword` |
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) | | `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) | | `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
| `kSecAttrAccessGroup` | `group.eu.mhsl.marianum.mobile.client.widget` | | `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) | | `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) | | Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
innerhalb des NSE-Budgets). Nicht implementiert. innerhalb des NSE-Budgets). Nicht implementiert.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) — 3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2). vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group 4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID). `keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` / `<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing- durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern. mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
vollqualifizierten Group-String verwenden.
-12
View File
@@ -4,8 +4,6 @@ PODS:
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_badge (2.0.0): - flutter_app_badge (2.0.0):
- Flutter - Flutter
- home_widget (0.0.1):
- Flutter
- open_filex (0.0.2): - open_filex (0.0.2):
- Flutter - Flutter
- PhoneNumberKit (3.7.11): - PhoneNumberKit (3.7.11):
@@ -14,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- receive_sharing_intent (1.8.1):
- Flutter
- workmanager_apple (0.0.1): - workmanager_apple (0.0.1):
- Flutter - Flutter
@@ -23,10 +19,8 @@ DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`) - flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
@@ -40,12 +34,8 @@ EXTERNAL SOURCES:
:path: Flutter :path: Flutter
flutter_app_badge: flutter_app_badge:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
workmanager_apple: workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios" :path: ".symlinks/plugins/workmanager_apple/ios"
@@ -53,10 +43,8 @@ SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6 PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
+188 -9
View File
@@ -12,14 +12,18 @@
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; }; 33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; }; AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; }; B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -37,6 +41,13 @@
remoteGlobalIDString = AA0101010000000011111111; remoteGlobalIDString = AA0101010000000011111111;
remoteInfo = TimetableWidgetExtension; remoteInfo = TimetableWidgetExtension;
}; };
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
remoteInfo = NotificationServiceExtension;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -48,6 +59,7 @@
files = ( files = (
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */, 3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */, AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -65,17 +77,24 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; }; 60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; 64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -94,10 +113,12 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; }; BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; }; BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; }; BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; }; DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; }; EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; }; F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -169,6 +190,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -185,15 +214,36 @@
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
isa = PBXGroup;
children = (
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
509DCCD474353408FE5806C5 /* PEM.swift */,
12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
);
name = NotificationServiceExtension;
path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT;
};
731388A08E3B330B216381D0 /* Frameworks */ = { 731388A08E3B330B216381D0 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */, 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */, 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
isa = PBXGroup;
children = (
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = { 9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -208,6 +258,9 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */, BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */, BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */, BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
); );
name = Flutter; name = Flutter;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -222,6 +275,7 @@
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */, 345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */, 731388A08E3B330B216381D0 /* Frameworks */,
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -231,6 +285,7 @@
97C146EE1CF9000F007C117D /* Runner.app */, 97C146EE1CF9000F007C117D /* Runner.app */,
3321F8052FB1C00C0011C712 /* Share Extension.appex */, 3321F8052FB1C00C0011C712 /* Share Extension.appex */,
AA0101020000000011111111 /* TimetableWidgetExtension.appex */, AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -278,9 +333,6 @@
productType = "com.apple.product-type.app-extension"; productType = "com.apple.product-type.app-extension";
}; };
97C146ED1CF9000F007C117D /* Runner */ = { 97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = ( buildPhases = (
@@ -299,8 +351,12 @@
dependencies = ( dependencies = (
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */, 3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
AA0101090000000011111111 /* PBXTargetDependency */, AA0101090000000011111111 /* PBXTargetDependency */,
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
); );
name = Runner; name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner; productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@@ -325,13 +381,27 @@
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension"; productType = "com.apple.product-type.app-extension";
}; };
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
buildPhases = (
DF8C1170E7DF96711030EAC0 /* Sources */,
D2447D92E9CCD96B3292B17B /* Frameworks */,
239068341DC6E6B36193EC96 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationServiceExtension;
productName = NotificationServiceExtension;
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = { 97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
@@ -360,6 +430,9 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -367,11 +440,19 @@
97C146ED1CF9000F007C117D /* Runner */, 97C146ED1CF9000F007C117D /* Runner */,
3321F8042FB1C00C0011C712 /* Share Extension */, 3321F8042FB1C00C0011C712 /* Share Extension */,
AA0101010000000011111111 /* TimetableWidgetExtension */, AA0101010000000011111111 /* TimetableWidgetExtension */,
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
239068341DC6E6B36193EC96 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3321F8032FB1C00C0011C712 /* Resources */ = { 3321F8032FB1C00C0011C712 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -520,6 +601,15 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
DF8C1170E7DF96711030EAC0 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@@ -528,6 +618,12 @@
target = 3321F8042FB1C00C0011C712 /* Share Extension */; target = 3321F8042FB1C00C0011C712 /* Share Extension */;
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */; targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
}; };
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationServiceExtension;
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
};
AA0101090000000011111111 /* PBXTargetDependency */ = { AA0101090000000011111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = AA0101010000000011111111 /* TimetableWidgetExtension */; target = AA0101010000000011111111 /* TimetableWidgetExtension */;
@@ -929,6 +1025,29 @@
}; };
name = Release; name = Release;
}; };
A2B046F78AD55232F08583F3 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA01010A0000000011111111 /* Debug */ = { AA01010A0000000011111111 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */; baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
@@ -1051,6 +1170,54 @@
}; };
name = Profile; name = Profile;
}; };
E5EE58583E83B7694F6C3C5E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F418DF43742DC2E30B161652 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -1094,13 +1261,25 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E5EE58583E83B7694F6C3C5E /* Release */,
A2B046F78AD55232F08583F3 /* Debug */,
F418DF43742DC2E30B161652 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git", "location" : "https://github.com/google/app-check.git",
"state" : { "state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5", "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.2.0" "version" : "11.3.1"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60", "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.13.0" "version" : "12.15.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.5.0" "version" : "3.6.1"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604", "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.13.0" "version" : "12.15.0"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e", "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.0" "version" : "8.1.2"
} }
}, },
{ {
@@ -122,8 +122,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git", "location" : "https://github.com/firebase/leveldb.git",
"state" : { "state" : {
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.2" "version" : "1.22.5"
} }
}, },
{ {
@@ -131,8 +131,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git", "location" : "https://github.com/firebase/nanopb.git",
"state" : { "state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.0" "version" : "2.30910.1"
} }
}, },
{ {
@@ -140,8 +140,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git", "location" : "https://github.com/google/promises.git",
"state" : { "state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.0" "version" : "2.4.1"
} }
}, },
{ {
+15 -1
View File
@@ -1,6 +1,7 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications import UserNotifications
import workmanager_apple
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -12,7 +13,7 @@ import UserNotifications
private let markReadActionId = "TALK_MARK_READ" private let markReadActionId = "TALK_MARK_READ"
// Shared (App Group) keychain same group as the NSE and the Dart side. // Shared (App Group) keychain same group as the NSE and the Dart side.
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget" private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private let usernameAccount = "nextcloud_username" private let usernameAccount = "nextcloud_username"
private let appPasswordAccount = "nextcloud_app_password" private let appPasswordAccount = "nextcloud_app_password"
private let baseUrlAccount = "nextcloud_base_url" private let baseUrlAccount = "nextcloud_base_url"
@@ -23,6 +24,19 @@ import UserNotifications
) -> Bool { ) -> Bool {
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions) let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
registerTalkCategory() 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 // FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
// forwards these callbacks to the plugins (firebase_messaging, // forwards these callbacks to the plugins (firebase_messaging,
// flutter_local_notifications). We route Talk actions natively here the // flutter_local_notifications). We route Talk actions natively here the
+4
View File
@@ -66,6 +66,10 @@
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
+1 -1
View File
@@ -11,7 +11,7 @@
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+2 -2
View File
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
) { ) {
super.scene(scene, willConnectTo: session, options: connectionOptions) super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts { for context in connectionOptions.urlContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application( _ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
) )
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts { for context in URLContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application( _ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
open: context.url, open: context.url,
options: [:] options: [:]
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
import AVFoundation import AVFoundation
// Datenmodell muss byte-für-byte zu dem passen, was // Datenmodell muss byte-für-byte zu dem passen, was
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert. // ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
private enum SharedMediaType: String, Codable { private enum SharedMediaType: String, Codable {
case image, video, text, file, url case image, video, text, file, url
} }
@@ -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 { private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Text(dayLabel(for: data.anchorDate)) Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.7)
Spacer(minLength: 4) Spacer(minLength: 4)
Text("Stand: \(freshnessLabel(for: data.fetchedAt))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .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 .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 .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 .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() 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 cal = Calendar.current
let today = cal.startOfDay(for: Date()) let today = cal.startOfDay(for: now)
let anchor = cal.startOfDay(for: date) let anchor = cal.startOfDay(for: date)
if anchor == today { if anchor == today {
return "Heute · \(shortDate(date))" 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 { if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
return "Morgen · \(shortDate(date))" return "Morgen · \(shortDate(date))"
} }
let formatter = DateFormatter() return WidgetDateFormatters.weekdayDate.string(from: date)
formatter.locale = Locale(identifier: "de_DE")
formatter.dateFormat = "EEEE · dd.MM."
return formatter.string(from: date)
} }
func shortDate(_ date: Date) -> String { func shortDate(_ date: Date) -> String {
let f = DateFormatter() WidgetDateFormatters.shortDate.string(from: date)
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM."
return f.string(from: date)
} }
func freshnessLabel(for fetchedAt: Date) -> String { func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
let cal = Calendar.current let cal = Calendar.current
let today = cal.startOfDay(for: Date()) let today = cal.startOfDay(for: now)
let fetchedDay = cal.startOfDay(for: fetchedAt) let fetchedDay = cal.startOfDay(for: fetchedAt)
let timeFmt = DateFormatter()
timeFmt.locale = Locale(identifier: "de_DE")
timeFmt.dateFormat = "HH:mm"
if fetchedDay == today { 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), if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
fetchedDay == yesterday { fetchedDay == yesterday {
return "gestern \(timeFmt.string(from: fetchedAt))" return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
} }
let dateTimeFmt = DateFormatter() return WidgetDateFormatters.dateTime.string(from: fetchedAt)
dateTimeFmt.locale = Locale(identifier: "de_DE")
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
return dateTimeFmt.string(from: fetchedAt)
} }
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.8)
Spacer() Spacer()
Text("Stand: \(freshnessLabel(for: data.fetchedAt))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
} }
private func weekday(for date: Date) -> String { private func weekday(for date: Date) -> String {
let f = DateFormatter() WidgetDateFormatters.weekdayShort.string(from: date)
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "EE"
return f.string(from: date)
} }
private func placeholder(_ message: String) -> some View { private func placeholder(_ message: String) -> some View {
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
in context: Context, in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void 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 // 30 min mirrors the Dart workmanager cadence. iOS treats this as
// advisory; the "Stand:" label tells the user when data is stale. // advisory; the boundary entries below keep the rendered day correct
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() // even when no reload is granted, and the "Stand:" label tells the
completion(Timeline(entries: [entry], policy: .after(next))) // 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, in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void completion: @escaping (Timeline<TimetableEntry>) -> Void
) { ) {
let entry = TimetableEntry.current(variant: .week) let now = Date()
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: [entry], policy: .after(next))) completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
} }
} }
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
themeMode: WidgetDataLoader.themeMode() 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 { extension View {
+23 -1
View File
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
case irregular case irregular
case teacherChanged case teacherChanged
case event 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 { struct WidgetLesson: Codable {
@@ -18,6 +27,8 @@ struct WidgetLesson: Codable {
let subjectShort: String let subjectShort: String
let subjectLong: String? let subjectLong: String?
let room: 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 teacher: String?
let originalTeacher: String? let originalTeacher: String?
let status: WidgetLessonStatus let status: WidgetLessonStatus
@@ -33,6 +44,12 @@ struct WidgetPeriod: Codable {
let virtualEndMinutes: Int let virtualEndMinutes: Int
} }
struct WidgetDayInfo: Codable {
let date: Date
let isHoliday: Bool
let holidayName: String?
}
struct WidgetTimetableData: Codable { struct WidgetTimetableData: Codable {
let fetchedAt: Date let fetchedAt: Date
let anchorDate: Date let anchorDate: Date
@@ -40,12 +57,17 @@ struct WidgetTimetableData: Codable {
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let isHoliday: Bool let isHoliday: Bool
let holidayName: String? 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 { enum WidgetDataKey {
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget" static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
static let dayData = "widget_data_day_v1" 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 loggedIn = "widget_data_logged_in_v1"
static let themeMode = "widget_setting_theme_mode_v1" static let themeMode = "widget_setting_theme_mode_v1"
} }
+17
View File
@@ -0,0 +1,17 @@
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
/// Demo fixtures for the absence-report form: the class dropdown and the
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
/// silently drift the demo shape away from what the queries parse.
class DemoAbsence {
const DemoAbsence._();
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
firstName: 'Max',
lastName: 'Mustermann',
className: '10a',
phone: '0123 456789',
);
}
+7 -1
View File
@@ -2,13 +2,19 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_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 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 { class DemoCapabilities {
const DemoCapabilities._(); const DemoCapabilities._();
static CapabilitiesState state() => const CapabilitiesState( static CapabilitiesState state() => const CapabilitiesState(
viewForeignTimetables: true, viewForeignTimetables: true,
pushNotifications: 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, loaded: true,
); );
} }
+5
View File
@@ -1,3 +1,4 @@
import 'data/demo_absence.dart';
import 'data/demo_breaker.dart'; import 'data/demo_breaker.dart';
import 'data/demo_holidays.dart'; import 'data/demo_holidays.dart';
import 'data/demo_timetable.dart'; import 'data/demo_timetable.dart';
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
.toList(); .toList();
case 'breaker': case 'breaker':
return DemoBreaker.none().toJson(); return DemoBreaker.none().toJson();
case 'absence/classes':
return DemoAbsence.classes();
case 'absence/prefill':
return DemoAbsence.prefill().toJson();
case 'timetable/elements/teachers': case 'timetable/elements/teachers':
case 'timetable/elements/students': case 'timetable/elements/students':
case 'timetable/elements/classes': case 'timetable/elements/classes':
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart'; import '../../../model/account_data.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via /// 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(); GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
/// Returns the freshly minted app password. Throws on any transport or /// Returns the freshly minted app password. Throws on any transport or
/// protocol error — callers treat push registration as best-effort and swallow /// protocol error — a 401 becomes an [AuthException], which the login flow
/// failures. /// reads as "Nextcloud rejects the password" (two-factor authentication or
/// password mismatch) and answers with the interactive Login Flow v2.
Future<String> run() async { Future<String> run() async {
final response = await _client.get( const label = 'Nextcloud getapppassword';
final response = (await sendGuarded(
label,
() => _client.get(
NextcloudOcs.uri('core/getapppassword'), NextcloudOcs.uri('core/getapppassword'),
headers: { headers: {
...NextcloudOcs.headers(), ...NextcloudOcs.headers(),
@@ -30,9 +35,13 @@ class GetAppPassword {
// this endpoint requires the real password. // this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(), 'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
}, },
); ),
))!;
if (response.statusCode < 200 || response.statusCode >= 300) { 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 json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data']; 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( GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
limit: 200, // Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50,
), ),
).run(), ).run(),
fromJson: GetChatResponse.fromJson, fromJson: GetChatResponse.fromJson,
@@ -0,0 +1,56 @@
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
/// when there are no older messages left — a normal "start of chat" outcome here.
/// `setReadMarker=off` so paging into history never moves the read cursor.
class GetChatHistory {
final String chatToken;
final int lastKnownMessageId;
final int limit;
GetChatHistory({
required this.chatToken,
required this.lastKnownMessageId,
required this.limit,
});
/// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off,
limit: limit,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
);
}
}
@@ -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);
}
}
+18 -5
View File
@@ -7,18 +7,31 @@ import '../../api_response.dart';
abstract class WebdavApi<T> { abstract class WebdavApi<T> {
T genericParams; T genericParams;
WebdavApi(this.genericParams) { WebdavApi(this.genericParams);
establishWebdavConnection();
}
Future<ApiResponse> run(); 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 => static Future<WebDavClient> establishWebdavConnection() async =>
NextcloudClient( NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'), 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(), loginName: AccountData().getUsername(),
).webdav; ).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) @JsonKey(defaultValue: false)
final bool pushNotifications; 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 MonSun 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({ CapabilitiesResponse({
required this.viewForeignTimetables, required this.viewForeignTimetables,
required this.pushNotifications, required this.pushNotifications,
this.timetablePastDays,
this.timetableFutureDays,
this.userType,
}); });
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) => factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -11,6 +11,9 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
) => CapabilitiesResponse( ) => CapabilitiesResponse(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] 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( Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -18,4 +21,7 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
) => <String, dynamic>{ ) => <String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables, 'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications, '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`) — /// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent /// upserts the stable install id, platform, app version and device info. Sent
/// once on app start and again once push registration completes that session /// on app start, again once push registration completes that session (so a
/// (so a fresh registration isn't under-reported until the next launch). /// 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 /// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call. /// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery { 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 /// 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 /// 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}) { static void report({required bool notificationsEnabled}) {
unawaited( unawaited(
TelemetryHeartbeat() TelemetryHeartbeat()
+35 -6
View File
@@ -18,6 +18,7 @@ import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.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/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.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 _knownTotalTabs = 1;
int _lastTabIndex = 0; int _lastTabIndex = 0;
bool _userOnLastTab = false; bool _userOnLastTab = false;
DateTime? _lastTelemetryAt;
static const Duration _chatListActiveInterval = Duration(seconds: 15); static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60); static const Duration _chatListIdleInterval = Duration(seconds: 60);
static const Duration _telemetryInterval = Duration(minutes: 15);
void _onTabControllerChanged() { void _onTabControllerChanged() {
final newIndex = Main.bottomNavigator.index; final newIndex = Main.bottomNavigator.index;
@@ -60,7 +63,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_syncChatListPolling(); _syncChatListPolling();
} }
void _syncChatListPolling() { void _syncChatListPolling({bool refresh = true}) {
if (!mounted) return; if (!mounted) return;
final modules = AppModule.getBottomBarModules(context); final modules = AppModule.getBottomBarModules(context);
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk); final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
@@ -70,19 +73,45 @@ class _AppState extends State<App> with WidgetsBindingObserver {
bloc.setAutoRefreshInterval( bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, 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 @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
log('AppLifecycle: $state'); log('AppLifecycle: $state');
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
_reportTelemetry();
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
if (!mounted) return; if (!mounted) return;
log('Refreshing due to LifecycleChange'); log('Refreshing due to LifecycleChange');
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
}); });
// updateProviders already refreshes the chat list; only re-arm the poll.
_syncChatListPolling(refresh: false);
_handlePendingWidgetNavigation(); _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 // Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh. // for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>(); final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) { _timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data; final data = state.data;
@@ -153,6 +183,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
WidgetPublisher.publishFromBlocState( WidgetPublisher.publishFromBlocState(
data, data,
settings: settingsCubit.val(), settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
), ),
); );
} }
@@ -164,6 +195,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
WidgetPublisher.publishFromBlocState( WidgetPublisher.publishFromBlocState(
initialData, initialData,
settings: settingsCubit.val(), settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
), ),
); );
} }
@@ -178,10 +210,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
TelemetryHeartbeat.report( _reportTelemetry();
notificationsEnabled:
context.read<SettingsCubit>().val().notificationSettings.enabled,
);
// A refreshed FCM token invalidates the existing push subscription — the // A refreshed FCM token invalidates the existing push subscription — the
// NC device identifier stays stable, so we simply re-register (NC first, // NC device identifier stays stable, so we simply re-register (NC first,
+97 -28
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
@@ -32,12 +33,20 @@ class WidgetBackgroundTask {
static const Duration periodicFrequency = Duration(minutes: 30); 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 { static Future<void> initialize() async {
await Workmanager().initialize(_callbackDispatcher); await Workmanager().initialize(_callbackDispatcher);
await Workmanager().registerPeriodicTask( await Workmanager().registerPeriodicTask(
periodicTaskName, periodicTaskName,
periodicTaskName, periodicTaskName,
frequency: periodicFrequency, 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), constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
backoffPolicy: BackoffPolicy.linear, 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( await Workmanager().registerOneOffTask(
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}', '$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
oneOffTaskName, 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 { static Future<void> cancelAll() async {
await Workmanager().cancelAll(); 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') @pragma('vm:entry-point')
void _callbackDispatcher() { void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
try { try {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await AccountData().waitForPopulation(); await WidgetBackgroundTask.runRefreshNow();
if (!AccountData().isPopulated()) {
log('[widget-bg] not logged in, skipping refresh');
await WidgetSync.setLoggedIn(false);
await WidgetSync.triggerUpdate();
return true;
}
await _refresh();
return true; return true;
} on Exception catch (e, s) { } on Exception catch (e, s) {
log('[widget-bg] refresh failed: $e', stackTrace: s); log('[widget-bg] refresh failed: $e', stackTrace: s);
@@ -94,39 +154,51 @@ Future<void> _refresh() async {
} }
final now = WidgetPublisher.widgetNow(); final now = WidgetPublisher.widgetNow();
// 14-day window so the week-widget rolls forward into next Monday's // Fetch window matches the week payload's window so the widget can roll
// lessons on Friday evening. // forward into next week's lessons without fresh data.
final weekStart = _startOfWeek(now); final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
final weekEndExclusive = weekStart.add(const Duration(days: 14)); 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, from: weekStart,
until: weekEndExclusive.subtract(const Duration(days: 1)), until: weekEndExclusive.subtract(const Duration(days: 1)),
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
// Reference data — failures fall through to null in the mapper rather
// than aborting the whole refresh.
final subjects = await _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
); );
final rooms = await _runOrNull<TimetableGetRoomsResponse>( final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
() => TimetableGetRooms().run(), () => TimetableGetRooms().run(),
); );
final holidays = await _runOrNull<TimetableGetHolidaysResponse>( final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
() => TimetableGetHolidays().run(), () => TimetableGetHolidays().run(),
); );
final timegrid = await _runOrNull<TimetableGetTimegridResponse>( final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(), () => TimetableGetTimegrid().run(),
); );
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>( final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
() => GetCustomTimetableEvent( () => GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()), GetCustomTimetableEventParams(AccountData().getUserSecret()),
).run(), ).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 lessons = timetable.entries;
final connectDouble = await WidgetSync.getConnectDoubleLessons(); final [connectDouble, isTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(),
]);
final dayData = WidgetDataMapper.buildDayData( final dayData = WidgetDataMapper.buildDayData(
now: now, now: now,
lessons: lessons, lessons: lessons,
@@ -136,6 +208,7 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
); );
final weekData = WidgetDataMapper.buildWeekData( final weekData = WidgetDataMapper.buildWeekData(
now: now, now: now,
@@ -146,6 +219,7 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
); );
await WidgetSync.writeDayData(dayData); 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 { Future<T?> _runOrNull<T>(Future<T> Function() task) async {
try { try {
return await task(); return await task();
+38
View File
@@ -17,6 +17,10 @@ class AccountData {
// token, so two registrations need two app passwords. // token, so two registrations need two app passwords.
static const _appPasswordField = 'nextcloud_app_password'; static const _appPasswordField = 'nextcloud_app_password';
static const _appPasswordTalkField = 'nextcloud_app_password_talk'; 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). // Persists the demo session across cold starts (see DemoMode).
static const _demoField = 'is_demo'; static const _demoField = 'is_demo';
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one. // Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
@@ -38,10 +42,17 @@ class AccountData {
String? _appPassword; String? _appPassword;
String? _appPasswordTalk; String? _appPasswordTalk;
bool _isDemo = false; bool _isDemo = false;
bool _usesLoginFlow = false;
/// True while the active session is a local demo session (see DemoMode). /// True while the active session is a local demo session (see DemoMode).
bool get isDemo => _isDemo; 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() { String getUsername() {
if (_username == null) throw Exception('Username not initialized'); if (_username == null) throw Exception('Username not initialized');
return _username!; return _username!;
@@ -86,9 +97,11 @@ class AccountData {
_appPassword = null; _appPassword = null;
_appPasswordTalk = null; _appPasswordTalk = null;
_isDemo = false; _isDemo = false;
_usesLoginFlow = false;
await _secureStorage.delete(key: _usernameField); await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField); await _secureStorage.delete(key: _passwordField);
await _secureStorage.delete(key: _demoField); await _secureStorage.delete(key: _demoField);
await _secureStorage.delete(key: _loginFlowField);
await _clearAppPasswordStorage(); await _clearAppPasswordStorage();
await _clearAppPasswordTalkStorage(); await _clearAppPasswordTalkStorage();
} }
@@ -111,6 +124,17 @@ class AccountData {
await _clearAppPasswordStorage(); 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; bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
/// Persists the app password backing the Talk push registration. /// Persists the app password backing the Talk push registration.
@@ -156,6 +180,7 @@ class AccountData {
_username = await _secureStorage.read(key: _usernameField); _username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField); _password = await _secureStorage.read(key: _passwordField);
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; _isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
try { try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField); _appPassword = await pushSecureStorage.read(key: _appPasswordField);
_appPasswordTalk = await pushSecureStorage.read( _appPasswordTalk = await pushSecureStorage.read(
@@ -209,6 +234,10 @@ class AccountData {
String getTalkBasicAuthHeader() { String getTalkBasicAuthHeader() {
_requirePopulated(); _requirePopulated();
if (!hasAppPasswordTalk()) { 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'); throw StateError('Talk app password not available yet');
} }
return _basicAuth(_appPasswordTalk!); return _basicAuth(_appPasswordTalk!);
@@ -222,6 +251,15 @@ class AccountData {
return _basicAuth(_password!); 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() { void _requirePopulated() {
if (!isPopulated()) { if (!isPopulated()) {
throw Exception( throw Exception(
+33 -1
View File
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:crypton/crypton.dart'; import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import '../background/widget_background_task.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
@@ -12,6 +13,10 @@ import 'push_registration_store.dart';
import 'push_renderer.dart'; import 'push_renderer.dart';
import 'push_subject.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. /// How an incoming FCM payload should be interpreted.
enum PushKind { enum PushKind {
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`). /// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
@@ -20,6 +25,11 @@ enum PushKind {
/// Plaintext MarianumConnect direct push (`source == "connect"`). /// Plaintext MarianumConnect direct push (`source == "connect"`).
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. /// Neither — ignored.
unknown, unknown,
} }
@@ -31,7 +41,10 @@ PushKind classifyPush(Map<String, dynamic> data) {
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false; final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false; final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
if (hasSubject && hasSignature) return PushKind.nextcloud; 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; return PushKind.unknown;
} }
@@ -97,11 +110,30 @@ class PushMessageHandler {
notificationsEnabled: notificationsEnabled, notificationsEnabled: notificationsEnabled,
); );
break; break;
case PushKind.widgetRefresh:
// Deliberately before any notificationsEnabled gate: silent sync
// pushes must work with notifications off.
await _handleWidgetRefresh();
break;
case PushKind.unknown: case PushKind.unknown:
break; 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( Future<void> _handleConnect(
RemoteMessage message, { RemoteMessage message, {
required bool foreground, required bool foreground,
+37 -1
View File
@@ -67,6 +67,13 @@ class PushRegistration {
/// registration binds to it, so it must be obtained before registering. /// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async { Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return; 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 { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword); await AccountData().setAppPassword(appPassword);
@@ -79,6 +86,11 @@ class PushRegistration {
/// (each `getapppassword` call with the real password mints a fresh one). /// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async { Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return; 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 { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword); await AccountData().setAppPasswordTalk(appPassword);
@@ -129,8 +141,19 @@ class PushRegistration {
appVersion = null; 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; var allOk = true;
for (final type in PushRegistrationType.values) { for (final type in types) {
final ok = await _registerType( final ok = await _registerType(
type: type, type: type,
fcmToken: fcmToken, fcmToken: fcmToken,
@@ -260,6 +283,19 @@ class PushRegistration {
await _store.clear(); 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 /// Pure decision for whether a persisted registration endpoint no longer
/// matches the currently active one. A missing/empty stored value never /// matches the currently active one. A missing/empty stored value never
/// forces a re-registration — old installs (pre endpoint-tracking) heal via /// forces a re-registration — old installs (pre endpoint-tracking) heal via
+8 -4
View File
@@ -5,11 +5,15 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// the server public key and the Nextcloud app password to decrypt pushes /// the server public key and the Nextcloud app password to decrypt pushes
/// while the app is not running. /// while the app is not running.
/// ///
/// The value reuses the existing app-group id already present in the iOS /// A team-prefixed keychain access group (`$(AppIdentifierPrefix)eu.mhsl…push`)
/// project (`ios/Runner/Runner.entitlements`). Phase 3 must additionally list /// listed under `keychain-access-groups` in both the Runner and the NSE
/// it under `keychain-access-groups` for both the Runner and the NSE target. /// entitlements. The literal `MY55VF3KPG.` prefix is the team's stable
/// AppIdentifierPrefix; Runner and NSE share the group because they sign with
/// the same team. (App-group ids like `group.*` can't be used here because the
/// Xcode-managed profiles only grant `<TeamID>.*` keychain groups.)
/// On Android `groupId` is ignored, so this is a no-op there. /// On Android `groupId` is ignored, so this is a no-op there.
const String kPushKeychainGroup = 'group.eu.mhsl.marianum.mobile.client.widget'; const String kPushKeychainGroup =
'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push';
/// [IOSOptions] used for every push-related secure-storage entry. Uses /// [IOSOptions] used for every push-related secure-storage entry. Uses
/// `first_unlock` accessibility so the NSE can read the key material after the /// `first_unlock` accessibility so the NSE can read the key material after the
+15
View File
@@ -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/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_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 '../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/files.dart';
import '../view/pages/files/sharing/sharee_picker_page.dart'; import '../view/pages/files/sharing/sharee_picker_page.dart';
import '../view/pages/foreign_timetable/element_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]. /// Opens the tappable, zoomable profile-picture viewer for [id].
static void openLargeProfilePicture(BuildContext context, String id) { static void openLargeProfilePicture(BuildContext context, String id) {
Navigator.of(context).push( Navigator.of(context).push(
+8
View File
@@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../storage/modules_settings.dart'; import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.dart';
import '../../../view/pages/files/files.dart'; import '../../../view/pages/files/files.dart';
import '../../../view/pages/grade_averages/grade_averages_view.dart'; import '../../../view/pages/grade_averages/grade_averages_view.dart';
import '../../../view/pages/holidays/holidays_view.dart'; import '../../../view/pages/holidays/holidays_view.dart';
@@ -137,6 +138,12 @@ class AppModule {
breakerArea: BreakerArea.dates, breakerArea: BreakerArea.dates,
create: MarianumDatesView.new, create: MarianumDatesView.new,
), ),
Modules.absenceReport: AppModule(
Modules.absenceReport,
name: 'Krankmeldung',
icon: () => Icon(Icons.sick_outlined),
create: AbsenceReportView.new,
),
}; };
if (!showFiltered) { if (!showFiltered) {
@@ -286,4 +293,5 @@ enum Modules {
gradeAveragesCalculator, gradeAveragesCalculator,
holidays, holidays,
marianumDates, marianumDates,
absenceReport,
} }
@@ -17,6 +17,14 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
bool get canReceivePushNotifications => state.pushNotifications; 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 /// Refreshes capabilities from the server. On any failure (endpoint not yet
/// live, network error, 4xx) the previously hydrated flags are kept but the /// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a /// state is marked `loaded` — a failed fetch never silently grants a
@@ -32,6 +40,9 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
CapabilitiesState( CapabilitiesState(
viewForeignTimetables: response.viewForeignTimetables, viewForeignTimetables: response.viewForeignTimetables,
pushNotifications: response.pushNotifications, pushNotifications: response.pushNotifications,
timetablePastDays: response.timetablePastDays,
timetableFutureDays: response.timetableFutureDays,
userType: response.userType,
loaded: true, loaded: true,
), ),
); );
@@ -8,6 +8,12 @@ abstract class CapabilitiesState with _$CapabilitiesState {
const factory CapabilitiesState({ const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables, @Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications, @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 // Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
@@ -15,7 +15,10 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$CapabilitiesState { 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 // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
bool get loaded; bool get loaded;
@@ -31,16 +34,16 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded); int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
@override @override
String toString() { 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; factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? 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( return _then(_self.copyWith(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable 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,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, 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) { switch (_that) {
case _CapabilitiesState() when $default != null: 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(); 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) { switch (_that) {
case _CapabilitiesState(): 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'); 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) { switch (_that) {
case _CapabilitiesState() when $default != null: 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; return null;
} }
@@ -214,11 +220,17 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
@JsonSerializable() @JsonSerializable()
class _CapabilitiesState implements CapabilitiesState { 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); factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
@override@JsonKey() final bool viewForeignTimetables; @override@JsonKey() final bool viewForeignTimetables;
@override@JsonKey() final bool pushNotifications; @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 // Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
@@ -237,16 +249,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded); int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
@override @override
String toString() { 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; factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? 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( return _then(_CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable 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,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, as bool,
)); ));
} }
@@ -10,6 +10,9 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
_CapabilitiesState( _CapabilitiesState(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] 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, loaded: json['loaded'] as bool? ?? false,
); );
@@ -17,5 +20,8 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
<String, dynamic>{ <String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables, 'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications, 'pushNotifications': instance.pushNotifications,
'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType,
'loaded': instance.loaded, 'loaded': instance.loaded,
}; };
+119 -3
View File
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart'; 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/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
@@ -56,7 +57,22 @@ class ChatBloc
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json); ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
@override @override
Map<String, dynamic>? toStorage(ChatState state) => state.toJson(); Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse;
if (response == null ||
response.data.length <= _kMaxPersistedMessages) {
return state.toJson();
}
// Keep only the newest N; trimming drops older messages, so there is
// definitely more history to page back in after a restart.
final newest = response
.sortByTimestamp()
.reversed
.take(_kMaxPersistedMessages)
.toSet();
final trimmed = GetChatResponse(newest)..headers = response.headers;
return state.copyWith(chatResponse: trimmed, hasMoreOld: true).toJson();
}
@override @override
Future<void> gatherData() async { Future<void> gatherData() async {
@@ -75,7 +91,16 @@ class ChatBloc
return; return;
} }
_stopLongPoll(); _stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); add(
Emit(
(s) => s.copyWith(
currentToken: token,
chatResponse: null,
hasMoreOld: true,
isLoadingOlder: false,
),
),
);
add(RefetchStarted<ChatState>()); add(RefetchStarted<ChatState>());
_scheduleLoad(token); _scheduleLoad(token);
} }
@@ -100,6 +125,40 @@ class ChatBloc
_stopLongPoll(); _stopLongPoll();
} }
/// Pages in the next block of messages older than the oldest currently held.
/// No-ops when nothing more can be loaded or a load is already in flight.
Future<void> loadOlder() async {
final state = innerState;
if (state == null) return;
final token = state.currentToken;
if (token.isEmpty) return;
if (state.isLoadingOlder || !state.hasMoreOld) return;
final response = state.chatResponse;
if (response == null) return;
final oldestId = _minMessageId(response);
if (oldestId <= 0) return;
add(Emit((s) => s.copyWith(isLoadingOlder: true)));
try {
final older = await GetChatHistory(
chatToken: token,
lastKnownMessageId: oldestId,
limit: _kOlderPageSize,
).run();
if (isClosed) return;
if ((innerState?.currentToken ?? '') != token) return;
if (older == null || older.data.isEmpty) {
add(Emit((s) => s.copyWith(hasMoreOld: false, isLoadingOlder: false)));
return;
}
_applyOlderResponse(older);
} on Object catch (e) {
log('Load older messages for $token failed: $e');
if (isClosed) return;
add(Emit((s) => s.copyWith(isLoadingOlder: false)));
}
}
Future<void> sendServerReadMarker(String token, int messageId) async { Future<void> sendServerReadMarker(String token, int messageId) async {
try { try {
await SetReadMarker( await SetReadMarker(
@@ -245,7 +304,15 @@ class ChatBloc
void _applyChatResponse(GetChatResponse incoming) { void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse; final current = innerState?.chatResponse;
if (current == null) { if (current == null) {
add(DataGathered((s) => s.copyWith(chatResponse: incoming))); // Initial load: a short first page means there is nothing older to page in.
add(
DataGathered(
(s) => s.copyWith(
chatResponse: incoming,
hasMoreOld: incoming.data.length >= _kInitialPageSize,
),
),
);
return; return;
} }
final byId = <int, GetChatResponseObject>{}; final byId = <int, GetChatResponseObject>{};
@@ -260,6 +327,32 @@ class ChatBloc
add(DataGathered((s) => s.copyWith(chatResponse: merged))); add(DataGathered((s) => s.copyWith(chatResponse: merged)));
} }
/// Merges an older history page. Unlike [_applyChatResponse] it keeps the
/// current headers — the older page's `x-chat-last-common-read` would regress
/// the read-status shown for already-visible messages.
void _applyOlderResponse(GetChatResponse older) {
final current = innerState?.chatResponse;
if (current == null) return;
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in older.data) {
byId.putIfAbsent(m.id, () => m);
}
final merged = GetChatResponse(byId.values.toSet())
..headers = current.headers;
add(
DataGathered(
(s) => s.copyWith(
chatResponse: merged,
hasMoreOld: older.data.length >= _kOlderPageSize,
isLoadingOlder: false,
),
),
);
}
int _maxMessageId(GetChatResponse? response) { int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0; if (response == null) return 0;
var max = 0; var max = 0;
@@ -269,6 +362,16 @@ class ChatBloc
return max; return max;
} }
int _minMessageId(GetChatResponse? response) {
if (response == null) return 0;
var min = 0;
for (final m in response.data) {
if (m.id <= 0) continue; // skip dummies
if (min == 0 || m.id < min) min = m.id;
}
return min;
}
/// Mirrors the server's own `lastMessage` selection (comments + voice only). /// Mirrors the server's own `lastMessage` selection (comments + voice only).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) { GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best; GetChatResponseObject? best;
@@ -288,3 +391,16 @@ class ChatBloc
} }
const _kLongPollLastGivenHeader = 'x-chat-last-given'; const _kLongPollLastGivenHeader = 'x-chat-last-given';
/// Small first page shown on open (keeps the initial request cheap). Must match
/// the `limit` in GetChatCache (get_chat_cache.dart).
const _kInitialPageSize = 50;
/// Upper bound on how many messages are persisted via HydratedBloc. The full
/// scrolled history stays in memory at runtime; only the newest this-many
/// survive a restart (older ones are re-fetchable via scroll-up).
const _kMaxPersistedMessages = 500;
/// Larger block fetched per scroll-up so paging back through history needs
/// fewer round trips.
const _kOlderPageSize = 200;
@@ -11,6 +11,8 @@ abstract class ChatState with _$ChatState {
@Default('') String currentToken, @Default('') String currentToken,
GetChatResponse? chatResponse, GetChatResponse? chatResponse,
int? referenceMessageId, int? referenceMessageId,
@Default(true) bool hasMoreOld,
@Default(false) bool isLoadingOlder,
}) = _ChatState; }) = _ChatState;
factory ChatState.fromJson(Map<String, Object?> json) => factory ChatState.fromJson(Map<String, Object?> json) =>
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$ChatState { mixin _$ChatState {
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; bool get hasMoreOld; bool get isLoadingOlder;
/// Create a copy of ChatState /// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $ChatStateCopyWith<ChatState> get copyWith => _$ChatStateCopyWithImpl<ChatState>
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
@override @override
String toString() { String toString() {
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
} }
@@ -48,7 +48,7 @@ abstract mixin class $ChatStateCopyWith<$Res> {
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl; factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
}); });
@@ -65,12 +65,14 @@ class _$ChatStateCopyWithImpl<$Res>
/// Create a copy of ChatState /// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
as int?, as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
as bool,
)); ));
} }
@@ -155,10 +157,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _ChatState() when $default != null: case _ChatState() when $default != null:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
return orElse(); return orElse();
} }
@@ -176,10 +178,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _ChatState(): case _ChatState():
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -196,10 +198,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _ChatState() when $default != null: case _ChatState() when $default != null:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
return null; return null;
} }
@@ -211,12 +213,14 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
@JsonSerializable() @JsonSerializable()
class _ChatState implements ChatState { class _ChatState implements ChatState {
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId}); const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId, this.hasMoreOld = true, this.isLoadingOlder = false});
factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json); factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json);
@override@JsonKey() final String currentToken; @override@JsonKey() final String currentToken;
@override final GetChatResponse? chatResponse; @override final GetChatResponse? chatResponse;
@override final int? referenceMessageId; @override final int? referenceMessageId;
@override@JsonKey() final bool hasMoreOld;
@override@JsonKey() final bool isLoadingOlder;
/// Create a copy of ChatState /// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -231,16 +235,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
@override @override
String toString() { String toString() {
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
} }
@@ -251,7 +255,7 @@ abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Re
factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl; factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
}); });
@@ -268,12 +272,14 @@ class __$ChatStateCopyWithImpl<$Res>
/// Create a copy of ChatState /// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
return _then(_ChatState( return _then(_ChatState(
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
as int?, as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
as bool,
)); ));
} }
@@ -12,6 +12,8 @@ _ChatState _$ChatStateFromJson(Map<String, dynamic> json) => _ChatState(
? null ? null
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>), : GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(), referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
); );
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) => Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
@@ -19,4 +21,6 @@ Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
'currentToken': instance.currentToken, 'currentToken': instance.currentToken,
'chatResponse': instance.chatResponse, 'chatResponse': instance.chatResponse,
'referenceMessageId': instance.referenceMessageId, 'referenceMessageId': instance.referenceMessageId,
'hasMoreOld': instance.hasMoreOld,
'isLoadingOlder': instance.isLoadingOlder,
}; };
+1
View File
@@ -39,4 +39,5 @@ const _$ModulesEnumMap = {
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
Modules.holidays: 'holidays', Modules.holidays: 'holidays',
Modules.marianumDates: 'marianumDates', Modules.marianumDates: 'marianumDates',
Modules.absenceReport: 'absenceReport',
}; };
+59 -12
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -6,13 +5,29 @@ import 'package:flutter/foundation.dart';
import '../../api/demo/demo_mode.dart'; import '../../api/demo/demo_mode.dart';
import '../../api/errors/auth_exception.dart'; import '../../api/errors/auth_exception.dart';
import '../../api/errors/error_mapper.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/device_token_name.dart';
import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/auth/token_storage.dart';
import '../../api/marianumconnect/queries/auth_login/auth_login.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 '../../model/account_data.dart';
import '../../push/push_registration.dart';
import '../../widget_data/widget_sync.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 /// 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. /// driven from a thin Stateful view and unit-tested without a widget tree.
class LoginController extends ChangeNotifier { class LoginController extends ChangeNotifier {
@@ -24,10 +39,8 @@ class LoginController extends ChangeNotifier {
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
String? get errorDetails => _errorDetails; String? get errorDetails => _errorDetails;
/// Returns `true` when the credential probe succeeded. The view should Future<LoginResult> submit(String username, String password) async {
/// then transition the AccountBloc to `loggedIn`. if (_loading) return LoginResult.failure;
Future<bool> submit(String username, String password) async {
if (_loading) return false;
_loading = true; _loading = true;
_errorMessage = null; _errorMessage = null;
_errorDetails = null; _errorDetails = null;
@@ -45,7 +58,7 @@ class LoginController extends ChangeNotifier {
await AccountData().setDemo(user); await AccountData().setDemo(user);
_loading = false; _loading = false;
notifyListeners(); notifyListeners();
return true; return LoginResult.success;
} }
try { try {
@@ -65,13 +78,13 @@ class LoginController extends ChangeNotifier {
tokenName: await DeviceTokenName.resolve(), tokenName: await DeviceTokenName.resolve(),
); );
await AccountData().setData(user, password); await AccountData().setData(user, password);
// Mint the Nextcloud app password now so it's ready for the push // Mint the Nextcloud app password now it doubles as the Nextcloud
// registration and subsequent NC calls. Non-blocking: on failure push // credential probe: a rejection means 2FA is active (or the NC password
// stays off and retries on the next start. // diverges) and the login must finish interactively in the browser.
unawaited(PushRegistration().ensureAppPassword()); final ncReady = await _prepareNextcloudAppPassword();
_loading = false; _loading = false;
notifyListeners(); notifyListeners();
return true; return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
await AccountData().removeData(); await AccountData().removeData();
@@ -83,7 +96,41 @@ class LoginController extends ChangeNotifier {
_errorDetails = errorToTechnicalDetails(e); _errorDetails = errorToTechnicalDetails(e);
_loading = false; _loading = false;
notifyListeners(); 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; 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'),
),
],
],
),
),
),
),
),
),
);
}
}
+19 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../login_controller.dart'; import '../login_controller.dart';
import 'login_error_banner.dart'; import 'login_error_banner.dart';
@@ -51,11 +52,27 @@ class _LoginCardState extends State<LoginCard> {
Future<void> _submit() async { Future<void> _submit() async {
if (widget.controller.loading) return; if (widget.controller.loading) return;
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
final ok = await widget.controller.submit( final result = await widget.controller.submit(
_usernameController.text, _usernameController.text,
_passwordController.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) => 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/app_progress_indicator.dart';
import '../../../widget/confirm_dialog.dart'; import '../../../widget/confirm_dialog.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import '../../../widget/route_transition_gate.dart';
class MessageView extends StatefulWidget { class MessageView extends StatefulWidget {
final String id; final String id;
@@ -35,11 +36,13 @@ class _MessageViewState extends State<MessageView> {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large()); return const Center(child: AppProgressIndicator.large());
} }
return SfPdfViewer.memory( return RouteTransitionGate(
builder: (context) => SfPdfViewer.memory(
snapshot.data!, snapshot.data!,
enableHyperlinkNavigation: true, enableHyperlinkNavigation: true,
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) => onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
ConfirmDialog.openBrowser(context, e.uri), ConfirmDialog.openBrowser(context, e.uri),
),
); );
}, },
), ),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 { Future<void> _showLogoutDialog(BuildContext context) async {
// Flip AccountBloc state only after the dialog fully closes: doing it from // Flip AccountBloc state only after the dialog fully closes: doing it from
// inside removeData (the previous approach) raced AsyncDialogAction's // inside removeData (the previous approach) raced AsyncDialogAction's
+59 -16
View File
@@ -42,9 +42,15 @@ class ChatView extends StatefulWidget {
class _ChatViewState extends State<ChatView> with RouteAware { class _ChatViewState extends State<ChatView> with RouteAware {
final ItemScrollController _itemScrollController = ItemScrollController(); final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _positionsListener =
ItemPositionsListener.create();
final TextEditingController _searchTextController = TextEditingController(); final TextEditingController _searchTextController = TextEditingController();
final Map<int, int> _matchIndices = {}; 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; bool _searchActive = false;
String _searchQuery = ''; String _searchQuery = '';
List<ChatSearchMatch> _matches = const []; List<ChatSearchMatch> _matches = const [];
@@ -63,9 +69,24 @@ class _ChatViewState extends State<ChatView> with RouteAware {
super.initState(); super.initState();
_chatBlocRef = context.read<ChatBloc>(); _chatBlocRef = context.read<ChatBloc>();
_chatListBlocRef = context.read<ChatListBloc>(); _chatListBlocRef = context.read<ChatListBloc>();
_positionsListener.itemPositions.addListener(_onScrollPositions);
NotificationTasks.clearNotificationsForChat(widget.room.token); 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 @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -93,6 +114,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
if (_subscribedRoute != null) { if (_subscribedRoute != null) {
AppRoutes.chatRouteObserver.unsubscribe(this); AppRoutes.chatRouteObserver.unsubscribe(this);
} }
_positionsListener.itemPositions.removeListener(_onScrollPositions);
_markAsReadFinal(); _markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token); _chatBlocRef?.leaveChat(widget.room.token);
_searchTextController.dispose(); _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; final total = messages.length;
_matchIndices _matchIndices
..clear() ..clear()
@@ -373,9 +379,27 @@ class _ChatViewState extends State<ChatView> with RouteAware {
final items = _buildMessages( final items = _buildMessages(
state.chatResponse!, state.chatResponse!,
).reversed.toList(); ).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( return ScrollablePositionedList.builder(
reverse: true, reverse: true,
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
itemPositionsListener: _positionsListener,
itemCount: items.length, itemCount: items.length,
itemBuilder: (ctx, idx) => items[idx], 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),
),
),
);
}
+31 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.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_cache.dart';
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.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 '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/app_progress_indicator.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/avatar_actions_sheet.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/large_profile_picture_view.dart';
import '../../../../widget/loading_spinner.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
import '../talk_navigator.dart'; import '../talk_navigator.dart';
import 'participants_list_view.dart'; import 'participants_list_view.dart';
import 'shared_items_view.dart';
class ChatInfo extends StatefulWidget { class ChatInfo extends StatefulWidget {
final GetRoomResponseObject room; final GetRoomResponseObject room;
@@ -26,6 +27,7 @@ class ChatInfo extends StatefulWidget {
class _ChatInfoState extends State<ChatInfo> { class _ChatInfoState extends State<ChatInfo> {
GetParticipantsResponse? participants; GetParticipantsResponse? participants;
GetSharedItemsOverviewResponse? _sharesOverview;
late bool _isFavorite; late bool _isFavorite;
int _avatarVersion = 0; int _avatarVersion = 0;
bool _avatarBusy = false; 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(); void _refreshList() => context.read<ChatListBloc>().refresh();
@@ -187,7 +199,15 @@ class _ChatInfoState extends State<ChatInfo> {
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
if (participants == null) 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 else
ListTile( ListTile(
leading: const Icon(Icons.supervised_user_circle), 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) if (_isFavorite)
AsyncListTile( AsyncListTile(
leading: const Icon(Icons.stars_outlined), 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/app_progress_indicator.dart';
import '../../../../widget/placeholder_view.dart'; import '../../../../widget/placeholder_view.dart';
import '../../../../widget/prosemirror/pm_json_view.dart'; import '../../../../widget/prosemirror/pm_json_view.dart';
import '../../../../widget/route_transition_gate.dart';
import 'ticker_content_card.dart'; import 'ticker_content_card.dart';
import 'ticker_updated_bar.dart'; import 'ticker_updated_bar.dart';
@@ -187,7 +188,9 @@ class _ProxiedFileViewState extends State<_ProxiedFileView> {
text: 'Das Dokument konnte nicht geladen werden.', 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'; import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
/// Combines back-to-back lessons with identical subject/room/teacher/status /// Combines back-to-back lessons with identical subject/room/teacher/status
@@ -44,6 +46,9 @@ class LessonMerger {
b.teachers.firstOrNull?.shortName) { b.teachers.firstOrNull?.shortName) {
return false; 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; if (a.status != b.status) return false;
// Lower bound on the gap — without it, two identical-metadata lessons that // Lower bound on the gap — without it, two identical-metadata lessons that
// overlap in time would silently collapse into one. // 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 '../../../../storage/timetable_settings.dart';
import 'arbitrary_appointment.dart'; import 'arbitrary_appointment.dart';
import 'lesson_color.dart'; import 'lesson_color.dart';
import 'lesson_labels.dart';
import 'lesson_merger.dart'; import 'lesson_merger.dart';
import 'lesson_status.dart'; import 'lesson_status.dart';
import 'lesson_type_label.dart'; import 'lesson_type_label.dart';
@@ -23,6 +24,10 @@ class TimetableAppointmentFactory {
final TimetableSettings settings; final TimetableSettings settings;
final DateTime now; 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({ TimetableAppointmentFactory({
required this.lessons, required this.lessons,
required this.customEvents, required this.customEvents,
@@ -30,6 +35,7 @@ class TimetableAppointmentFactory {
required this.settings, required this.settings,
required this.now, required this.now,
this.holidays = const [], this.holidays = const [],
this.showClassInsteadOfTeacher = false,
}); });
List<Appointment> build() { List<Appointment> build() {
@@ -130,7 +136,7 @@ class TimetableAppointmentFactory {
location: event.description.trim().isEmpty location: event.description.trim().isEmpty
? null ? null
: event.description.trim(), : event.description.trim(),
subject: _collapseWhitespace(event.title) ?? event.title, subject: collapseWhitespace(event.title) ?? event.title,
recurrenceRule: parsed.rule, recurrenceRule: parsed.rule,
recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates, recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates,
color: color:
@@ -222,7 +228,7 @@ class TimetableAppointmentFactory {
TimetableNameMode.longName => lookup?.longName ?? subjectShort, TimetableNameMode.longName => lookup?.longName ?? subjectShort,
TimetableNameMode.alternateName => lookup?.longName ?? subjectShort, TimetableNameMode.alternateName => lookup?.longName ?? subjectShort,
}; };
final collapsed = _collapseWhitespace(name); final collapsed = collapseWhitespace(name);
if (collapsed != null) return collapsed; if (collapsed != null) return collapsed;
} }
// Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc. // Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc.
@@ -233,10 +239,13 @@ class TimetableAppointmentFactory {
String _locationLabel(McTimetableEntry lesson) { String _locationLabel(McTimetableEntry lesson) {
final roomName = final roomName =
_collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt'; collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
final teacherName = // Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück.
_teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt'; final secondLine =
return '$roomName\n$teacherName'; (showClassInsteadOfTeacher ? lesson.classLabel : null) ??
_teacherLabel(lesson.teachers.firstOrNull) ??
'Unbekannt';
return '$roomName\n$secondLine';
} }
/// Backend serves teachers with their full display name ("Stefan Müller"), /// 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. /// overview; the detail sheet still renders the full name as a subtitle.
static String? _teacherLabel(McTimetableTeacher? teacher) { static String? _teacherLabel(McTimetableTeacher? teacher) {
if (teacher == null) return null; if (teacher == null) return null;
final display = _collapseWhitespace(teacher.displayName); final display = collapseWhitespace(teacher.displayName);
if (display != null && display.isNotEmpty) { if (display != null && display.isNotEmpty) {
final parts = display.split(' '); final parts = display.split(' ');
return parts.isEmpty ? display : parts.last; return parts.isEmpty ? display : parts.last;
} }
return _collapseWhitespace(teacher.shortName); 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;
} }
} }
+5 -3
View File
@@ -101,9 +101,8 @@ class _TimetableState extends State<Timetable> {
final loadableState = context.watch<TimetableBloc>().state; final loadableState = context.watch<TimetableBloc>().state;
final innerState = loadableState.data; final innerState = loadableState.data;
final atToday = innerState != null && _isOnInitialWeek(innerState); final atToday = innerState != null && _isOnInitialWeek(innerState);
final canViewForeign = context final capabilities = context.watch<CapabilitiesCubit>();
.watch<CapabilitiesCubit>() final canViewForeign = capabilities.canViewForeignTimetables;
.canViewForeignTimetables;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen // Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
@@ -166,6 +165,7 @@ class _TimetableState extends State<Timetable> {
), ),
onCreateEvent: _onCreateEventAt, onCreateEvent: _onCreateEventAt,
customEvents: state.customEvents?.events ?? const [], customEvents: state.customEvents?.events ?? const [],
showClassInsteadOfTeacher: capabilities.isTeacher,
), ),
), ),
); );
@@ -217,6 +217,8 @@ class _TimetableState extends State<Timetable> {
onAppointmentTap: (apt) => onAppointmentTap: (apt) =>
AppointmentDetailsDispatcher.show(context, state, apt), AppointmentDetailsDispatcher.show(context, state, apt),
customEvents: const [], 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 '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../extensions/date_time.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/settings/bloc/settings_cubit.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../storage/timetable_settings.dart'; import '../../../../storage/timetable_settings.dart';
@@ -27,6 +28,10 @@ class TimetableCalendarView extends StatefulWidget {
final void Function(DateTime start, DateTime end)? onCreateEvent; final void Function(DateTime start, DateTime end)? onCreateEvent;
final List<CustomTimetableEvent> customEvents; 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({ const TimetableCalendarView({
super.key, super.key,
required this.state, required this.state,
@@ -34,6 +39,7 @@ class TimetableCalendarView extends StatefulWidget {
required this.onAppointmentTap, required this.onAppointmentTap,
this.onCreateEvent, this.onCreateEvent,
this.customEvents = const [], this.customEvents = const [],
this.showClassInsteadOfTeacher = false,
}); });
@override @override
@@ -45,9 +51,9 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
GlobalKey<CustomWorkWeekCalendarState>(); GlobalKey<CustomWorkWeekCalendarState>();
List<Appointment>? _cachedAppointments; List<Appointment>? _cachedAppointments;
int? _lastDataVersion; // TimetableSettings and List define no `==`, so record equality degrades to
TimetableSettings? _lastTimetableSettings; // the same identity checks the cache always used.
List<CustomTimetableEvent>? _lastCustomEvents; (int, TimetableSettings, List<CustomTimetableEvent>, bool)? _cacheKey;
DateTime _initialDisplayDate() => DateTime.now().addDays(2); DateTime _initialDisplayDate() => DateTime.now().addDays(2);
@@ -62,15 +68,16 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
.watch<SettingsCubit>() .watch<SettingsCubit>()
.val() .val()
.timetableSettings; .timetableSettings;
if (_cachedAppointments != null && final key = (
_lastDataVersion == state.dataVersion && state.dataVersion,
identical(_lastTimetableSettings, timetableSettings) && timetableSettings,
identical(_lastCustomEvents, widget.customEvents)) { widget.customEvents,
widget.showClassInsteadOfTeacher,
);
if (_cachedAppointments != null && _cacheKey == key) {
return _cachedAppointments!; return _cachedAppointments!;
} }
_lastDataVersion = state.dataVersion; _cacheKey = key;
_lastTimetableSettings = timetableSettings;
_lastCustomEvents = widget.customEvents;
return _cachedAppointments = TimetableAppointmentFactory( return _cachedAppointments = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(), lessons: state.getAllKnownLessons().toList(),
@@ -79,6 +86,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
holidays: state.schoolHolidays?.result ?? const [], holidays: state.schoolHolidays?.result ?? const [],
settings: timetableSettings, settings: timetableSettings,
now: DateTime.now(), now: DateTime.now(),
showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher,
).build(); ).build();
} }
@@ -105,7 +113,12 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
disabledColor: Theme.of(context).disabledColor, disabledColor: Theme.of(context).disabledColor,
).build(); ).build();
final (minDate, maxDate) = _scrollBounds(state); final capabilities = context.watch<CapabilitiesCubit>();
final (minDate, maxDate) = _scrollBounds(
state,
pastDays: capabilities.timetablePastDays,
futureDays: capabilities.timetableFutureDays,
);
return CustomWorkWeekCalendar( return CustomWorkWeekCalendar(
key: _calendarKey, 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. /// 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 /// Starts from the (server-narrowed) Webuntis school year or a tight window
/// loaded yet), tightens by anything the bloc has learned from past denials, /// when that hasn't loaded yet, tightens by anything the bloc has learned
/// and finally clamps to a fixed window around today. /// from past denials, and finally clamps to the window Connect grants via
(DateTime, DateTime) _scrollBounds(TimetableState state) { /// [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 MonSun 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 year = state.schoolyear;
final DateTime baseMin; final DateTime baseMin;
final DateTime baseMax; final DateTime baseMax;
@@ -154,39 +171,43 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
? state.accessibleEndDate! ? state.accessibleEndDate!
: baseMax) : baseMax)
: baseMax; : baseMax;
final todayMonday = _mondayOf(DateTime.now()); final today = _startOfDay(DateTime.now());
final cappedMin = effectiveMin.isBefore( final todayMonday = _mondayOf(today);
todayMonday.subtractDays(_maxWeeksBack * 7), final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
) final capMin = pastDays == null
? todayMonday.subtractDays(_maxWeeksBack * 7) ? 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; : effectiveMin;
final cappedMax = effectiveMax.isAfter( final cappedMax = capMax != null && effectiveMax.isAfter(capMax)
todayMonday.addDays(_maxWeeksForward * 7 + 6), ? capMax
)
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
: effectiveMax; : effectiveMax;
// When the resulting range does not cover the current week — the summer gap // 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 // between two school years, or a stale persisted bound — fall back to the
// full fixed window around today. Otherwise the PageView clamps the initial // current week, widened to whatever the capabilities still allow. Otherwise
// page to the last week before the holidays (hiding the "Schulfrei" region) // the PageView clamps the initial page to the last week before the holidays
// and forward scrolling collapses to the current week only. // (hiding the "Schulfrei" region) and forward scrolling collapses to the
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); // current week only.
final outsideRange = final outsideRange =
cappedMax.isBefore(todayMonday) || cappedMin.isAfter(currentWeekEnd); cappedMax.isBefore(todayMonday) || cappedMin.isAfter(currentWeekEnd);
final finalMin = outsideRange final finalMin = outsideRange ? (capMin ?? todayMonday) : cappedMin;
? todayMonday.subtractDays(_maxWeeksBack * 7) final finalMax = outsideRange ? (capMax ?? currentWeekEnd) : cappedMax;
: cappedMin;
final finalMax = outsideRange
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
: cappedMax;
final daysToMonday = final daysToMonday =
(DateTime.monday - finalMin.weekday) % DateTime.daysPerWeek; (DateTime.monday - finalMin.weekday) % DateTime.daysPerWeek;
final mondayMin = finalMin.addDays(daysToMonday); final mondayMin = finalMin.addDays(daysToMonday);
return (mondayMin, finalMax); return (mondayMin, finalMax);
} }
static DateTime _mondayOf(DateTime d) { static DateTime _mondayOf(DateTime d) =>
final monday = d.subtractDays(d.weekday - 1); _startOfDay(d.subtractDays(d.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
} 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 -2
View File
@@ -7,6 +7,7 @@ import '../../utils/downloads/download_job.dart';
import '../../utils/downloads/download_manager.dart'; import '../../utils/downloads/download_manager.dart';
import '../../utils/haptics.dart'; import '../../utils/haptics.dart';
import 'downloads_sheet.dart'; import 'downloads_sheet.dart';
import 'stale_download_guard.dart';
/// Decides whether a just-finished download should open straight in the viewer. /// 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) { void _openJob(DownloadJob job) {
final path = job.localPath; final path = job.localPath;
_manager.markOpened(job);
final ctx = AppRoutes.overlayContext; 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(); Haptics.success();
AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile); AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile);
} }
+5 -1
View File
@@ -10,6 +10,7 @@ import '../../view/pages/files/data/file_type_icon.dart';
import '../centered_leading.dart'; import '../centered_leading.dart';
import '../details_bottom_sheet.dart'; import '../details_bottom_sheet.dart';
import '../info_dialog.dart'; import '../info_dialog.dart';
import 'stale_download_guard.dart';
/// Overview of all active and finished-but-unopened downloads. Lets the user /// Overview of all active and finished-but-unopened downloads. Lets the user
/// open/switch between finished files, cancel running ones and retry failures. /// open/switch between finished files, cancel running ones and retry failures.
@@ -86,8 +87,11 @@ class _DownloadsListState extends State<_DownloadsList> {
void _open(DownloadJob job) { void _open(DownloadJob job) {
final path = job.localPath; final path = job.localPath;
if (path == null) return; 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(); Navigator.of(widget.sheetContext).pop();
if (!ensureDownloadStillExists(widget.rootContext, job)) return;
Haptics.success();
DownloadManager.instance.markOpened(job); DownloadManager.instance.markOpened(job);
AppRoutes.openFileViewer( AppRoutes.openFileViewer(
widget.rootContext, 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;
}
+43 -1
View File
@@ -14,8 +14,11 @@ import 'package:share_plus/share_plus.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../utils/downloads/download_manager.dart';
import 'app_progress_indicator.dart'; import 'app_progress_indicator.dart';
import 'async_action_button.dart';
import 'centered_leading.dart'; import 'centered_leading.dart';
import 'confirm_dialog.dart';
import 'file_viewer/code_line.dart'; import 'file_viewer/code_line.dart';
import 'file_viewer/deferred_pdf_viewer.dart'; import 'file_viewer/deferred_pdf_viewer.dart';
import 'file_viewer/file_kind.dart'; import 'file_viewer/file_kind.dart';
@@ -82,9 +85,43 @@ class _FileViewerState extends State<FileViewer> {
super.dispose(); 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 { Future<void> _handleAction(FileViewingActions value) async {
switch (value) { switch (value) {
case FileViewingActions.openExternal: case FileViewingActions.openExternal:
if (!_ensureLocalFile()) return;
AppRoutes.openFileViewer( AppRoutes.openFileViewer(
context, context,
widget.path, widget.path,
@@ -99,16 +136,21 @@ class _FileViewerState extends State<FileViewer> {
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!); AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
break; break;
case FileViewingActions.share: case FileViewingActions.share:
if (!_ensureLocalFile()) return;
unawaited( unawaited(
SharePlus.instance.share( runWithErrorDialog(
context,
() => SharePlus.instance.share(
ShareParams( ShareParams(
files: [XFile(widget.path)], files: [XFile(widget.path)],
sharePositionOrigin: SharePositionOrigin.get(context), sharePositionOrigin: SharePositionOrigin.get(context),
), ),
), ),
),
); );
break; break;
case FileViewingActions.save: case FileViewingActions.save:
if (!_ensureLocalFile()) return;
try { try {
final source = File(widget.path); final source = File(widget.path);
final size = await source.length(); final size = await source.length();
@@ -3,51 +3,17 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.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 /// SfPdfViewer asserts on `localToGlobal` if laid out while a route
/// animation. Defer until the route enter animation completes. /// transition's fresh `RenderTransform` has no size yet. Mount it only while
class DeferredPdfViewer extends StatefulWidget { /// the route is at rest (see [RouteTransitionGate]).
class DeferredPdfViewer extends StatelessWidget {
const DeferredPdfViewer({super.key, required this.path}); const DeferredPdfViewer({super.key, required this.path});
final String path; final String path;
@override @override
State<DeferredPdfViewer> createState() => _DeferredPdfViewerState(); Widget build(BuildContext context) => RouteTransitionGate(
} builder: (context) => SfPdfViewer.file(File(path)),
);
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));
}
} }
+82
View File
@@ -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);
}
}
+6 -2
View File
@@ -230,11 +230,15 @@ class _UserAvatarState extends State<UserAvatar> {
final pending = _pendingAvatars.putIfAbsent(url, () { final pending = _pendingAvatars.putIfAbsent(url, () {
final future = _fetch(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)) { if (identical(_pendingAvatars[url], future)) {
_pendingAvatars.remove(url); _pendingAvatars.remove(url);
} }
}); }),
);
return future; return future;
}); });
+19
View File
@@ -28,6 +28,9 @@ abstract class WidgetLesson with _$WidgetLesson {
required String subjectShort, required String subjectShort,
String? subjectLong, String? subjectLong,
String? room, 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? teacher,
String? originalTeacher, String? originalTeacher,
required WidgetLessonStatus status, required WidgetLessonStatus status,
@@ -61,6 +64,20 @@ abstract class WidgetPeriod with _$WidgetPeriod {
_$WidgetPeriodFromJson(json); _$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 @freezed
abstract class WidgetTimetableData with _$WidgetTimetableData { abstract class WidgetTimetableData with _$WidgetTimetableData {
const factory WidgetTimetableData({ const factory WidgetTimetableData({
@@ -73,6 +90,8 @@ abstract class WidgetTimetableData with _$WidgetTimetableData {
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods, @Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
@Default(false) bool isHoliday, @Default(false) bool isHoliday,
String? holidayName, String? holidayName,
/// Week payload only: one entry per day of the covered window.
@Default(<WidgetDayInfo>[]) List<WidgetDayInfo> days,
}) = _WidgetTimetableData; }) = _WidgetTimetableData;
factory WidgetTimetableData.fromJson(Map<String, Object?> json) => factory WidgetTimetableData.fromJson(Map<String, Object?> json) =>
+301 -20
View File
@@ -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 /// @nodoc
mixin _$WidgetTimetableData { mixin _$WidgetTimetableData {
DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor. DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
/// For the day variant: the rendered school day. /// For the day variant: the rendered school day.
/// For the week variant: the Monday of the rendered school week. /// 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 /// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -612,16 +882,16 @@ $WidgetTimetableDataCopyWith<WidgetTimetableData> get copyWith => _$WidgetTimeta
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? 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( return _then(_self.copyWith(
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable 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 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<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 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 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) { switch (_that) {
case _WidgetTimetableData() when $default != null: 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(); 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) { switch (_that) {
case _WidgetTimetableData(): 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'); 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) { switch (_that) {
case _WidgetTimetableData() when $default != null: 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; return null;
} }
@@ -798,7 +1069,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
@JsonSerializable() @JsonSerializable()
class _WidgetTimetableData implements WidgetTimetableData { 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); factory _WidgetTimetableData.fromJson(Map<String, dynamic> json) => _$WidgetTimetableDataFromJson(json);
@override final DateTime fetchedAt; @override final DateTime fetchedAt;
@@ -822,6 +1093,15 @@ class _WidgetTimetableData implements WidgetTimetableData {
@override@JsonKey() final bool isHoliday; @override@JsonKey() final bool isHoliday;
@override final String? holidayName; @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 /// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -836,16 +1116,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? 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( return _then(_WidgetTimetableData(
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable 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 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<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 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 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>,
)); ));
} }
+20
View File
@@ -63,6 +63,20 @@ Map<String, dynamic> _$WidgetPeriodToJson(_WidgetPeriod instance) =>
'virtualEndMinutes': instance.virtualEndMinutes, '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 _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
_WidgetTimetableData( _WidgetTimetableData(
fetchedAt: DateTime.parse(json['fetchedAt'] as String), fetchedAt: DateTime.parse(json['fetchedAt'] as String),
@@ -77,6 +91,11 @@ _WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
const <WidgetPeriod>[], const <WidgetPeriod>[],
isHoliday: json['isHoliday'] as bool? ?? false, isHoliday: json['isHoliday'] as bool? ?? false,
holidayName: json['holidayName'] as String?, 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( Map<String, dynamic> _$WidgetTimetableDataToJson(
@@ -88,4 +107,5 @@ Map<String, dynamic> _$WidgetTimetableDataToJson(
'periods': instance.periods, 'periods': instance.periods,
'isHoliday': instance.isHoliday, 'isHoliday': instance.isHoliday,
'holidayName': instance.holidayName, 'holidayName': instance.holidayName,
'days': instance.days,
}; };
+88 -12
View File
@@ -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/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/custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.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_merger.dart';
import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart';
import '../view/pages/timetable/data/lesson_status.dart'; import '../view/pages/timetable/data/lesson_status.dart';
@@ -34,12 +36,20 @@ class WidgetDataMapper {
return candidate; return candidate;
} }
static DateTime resolveWeekAnchor(DateTime now) { static DateTime resolveWeekAnchor(DateTime now) =>
final anchor = resolveDayAnchor(now); startOfCalendarWeek(resolveDayAnchor(now));
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
/// 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); 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({ static WidgetTimetableData buildDayData({
required DateTime now, required DateTime now,
required Iterable<McTimetableEntry> lessons, required Iterable<McTimetableEntry> lessons,
@@ -49,6 +59,7 @@ class WidgetDataMapper {
TimetableGetTimegridResponse? timegrid, TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents, GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true, bool connectDoubleLessons = true,
bool showClassInsteadOfTeacher = false,
}) { }) {
final anchor = resolveDayAnchor(now); final anchor = resolveDayAnchor(now);
final holiday = _findHoliday(anchor, holidays); final holiday = _findHoliday(anchor, holidays);
@@ -59,7 +70,13 @@ class WidgetDataMapper {
? LessonMerger.merge(dayLessons) ? LessonMerger.merge(dayLessons)
: dayLessons; : dayLessons;
final mapped = <WidgetLesson>[ final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)), ..._mapAll(
source,
now,
subjects,
rooms,
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
),
..._expandCustomEvents(customEvents, dayStart, dayEnd), ..._expandCustomEvents(customEvents, dayStart, dayEnd),
]..sort((a, b) => a.start.compareTo(b.start)); ]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData( return WidgetTimetableData(
@@ -81,12 +98,17 @@ class WidgetDataMapper {
TimetableGetTimegridResponse? timegrid, TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents, GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true, bool connectDoubleLessons = true,
bool showClassInsteadOfTeacher = false,
}) { }) {
final anchor = resolveWeekAnchor(now); 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 weekLessons = lessons.where((l) {
final dt = l.startDateTime; final dt = l.startDateTime;
return !dt.isBefore(anchor) && dt.isBefore(endExclusive); return !dt.isBefore(windowStart) && dt.isBefore(endExclusive);
}).toList(); }).toList();
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with // Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
// a 1st-period lesson on Tue if subject/teacher match. // a 1st-period lesson on Tue if subject/teacher match.
@@ -94,14 +116,45 @@ class WidgetDataMapper {
? _mergePerDay(weekLessons) ? _mergePerDay(weekLessons)
: weekLessons; : weekLessons;
final mapped = <WidgetLesson>[ final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)), ..._mapAll(
..._expandCustomEvents(customEvents, anchor, endExclusive), source,
now,
subjects,
rooms,
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
),
..._expandCustomEvents(customEvents, windowStart, endExclusive),
]..sort((a, b) => a.start.compareTo(b.start)); ]..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( return WidgetTimetableData(
fetchedAt: now, fetchedAt: now,
anchorDate: anchor, anchorDate: anchor,
lessons: _resolveCollisions(mapped), lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid), 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)]; 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( static WidgetLesson _mapLesson(
McTimetableEntry lesson, McTimetableEntry lesson,
DateTime now, DateTime now,
TimetableGetSubjectsResponse? subjects, TimetableGetSubjectsResponse? subjects,
TimetableGetRoomsResponse? rooms, TimetableGetRoomsResponse? rooms, {
) { bool showClassInsteadOfTeacher = false,
}) {
final start = lesson.startDateTime; final start = lesson.startDateTime;
final end = lesson.endDateTime; final end = lesson.endDateTime;
final status = _mapStatus( final status = _mapStatus(
@@ -276,8 +346,14 @@ class WidgetDataMapper {
roomName; roomName;
} }
final teacher = lesson.teachers.firstOrNull; final teacher = lesson.teachers.firstOrNull;
final teacherName = teacher?.shortName; // Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
final originalTeacher = teacher?.originalShortName; // 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( return WidgetLesson(
start: start, start: start,
end: end, end: end,
+9 -3
View File
@@ -22,14 +22,18 @@ class WidgetPublisher {
static Future<void> publishFromBlocState( static Future<void> publishFromBlocState(
TimetableState state, { TimetableState state, {
Settings? settings, Settings? settings,
bool isTeacher = false,
}) async { }) async {
try { try {
final connectDouble = final connectDouble =
settings?.timetableSettings.connectDoubleLessons ?? true; settings?.timetableSettings.connectDoubleLessons ?? true;
// Mirror into widget storage so the background isolate sees the same // Mirror into widget storage so the background isolate sees the same
// value the user just toggled. // values the user just toggled — concurrently, they are independent.
await WidgetSync.setConnectDoubleLessons(connectDouble); await Future.wait([
await WidgetSync.setThemeMode(_themeName(settings?.appTheme)); WidgetSync.setConnectDoubleLessons(connectDouble),
WidgetSync.setThemeMode(_themeName(settings?.appTheme)),
WidgetSync.setIsTeacher(isTeacher),
]);
final lessons = state.getAllKnownLessons(); final lessons = state.getAllKnownLessons();
final now = widgetNow(); final now = widgetNow();
final dayData = WidgetDataMapper.buildDayData( final dayData = WidgetDataMapper.buildDayData(
@@ -41,6 +45,7 @@ class WidgetPublisher {
timegrid: state.timegrid, timegrid: state.timegrid,
customEvents: state.customEvents, customEvents: state.customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
); );
final weekData = WidgetDataMapper.buildWeekData( final weekData = WidgetDataMapper.buildWeekData(
now: now, now: now,
@@ -51,6 +56,7 @@ class WidgetPublisher {
timegrid: state.timegrid, timegrid: state.timegrid,
customEvents: state.customEvents, customEvents: state.customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
); );
await WidgetSync.writeDayData(dayData); await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData); await WidgetSync.writeWeekData(weekData);
+38 -20
View File
@@ -12,14 +12,18 @@ class WidgetSync {
static const String iosAppGroupId = static const String iosAppGroupId =
'group.eu.mhsl.marianum.mobile.client.widget'; '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 androidDayProvider = 'TimetableDayWidget';
static const String androidWeekProvider = 'TimetableWeekWidget'; static const String androidWeekProvider = 'TimetableWeekWidget';
// `_v1` suffix lets a future schema change invalidate stale snapshots // Version suffix lets a schema change invalidate stale snapshots by
// by bumping the key instead of risking a parse crash. // bumping the key instead of risking a parse crash.
static const String dayDataKey = 'widget_data_day_v1'; 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 fetchedAtKey = 'widget_data_fetched_at_v1';
static const String loggedInKey = 'widget_data_logged_in_v1'; static const String loggedInKey = 'widget_data_logged_in_v1';
// Mirrored into widget storage so the background isolate can read it // Mirrored into widget storage so the background isolate can read it
@@ -27,6 +31,9 @@ class WidgetSync {
static const String connectDoubleLessonsKey = static const String connectDoubleLessonsKey =
'widget_setting_connect_double_lessons_v1'; 'widget_setting_connect_double_lessons_v1';
static const String themeModeKey = 'widget_setting_theme_mode_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 // Mirrored so the background isolate hits the same Marianum-Connect base
// URL the in-app settings cubit currently has selected. // URL the in-app settings cubit currently has selected.
static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1'; static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1';
@@ -54,25 +61,30 @@ class WidgetSync {
); );
} }
static Future<void> setLoggedIn(bool loggedIn) async { static Future<void> setLoggedIn(bool loggedIn) =>
await ensureInitialized(); _setBool(loggedInKey, loggedIn);
await HomeWidget.saveWidgetData<bool>(loggedInKey, loggedIn);
}
static Future<void> setConnectDoubleLessons(bool value) async { static Future<void> setConnectDoubleLessons(bool value) =>
await ensureInitialized(); _setBool(connectDoubleLessonsKey, value);
await HomeWidget.saveWidgetData<bool>(connectDoubleLessonsKey, value);
}
/// Default `true` matches `default_settings.dart` — fresh install behaves /// Default `true` matches `default_settings.dart` — fresh install behaves
/// like the in-app calendar. /// 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(); await ensureInitialized();
final value = await HomeWidget.getWidgetData<bool>( await HomeWidget.saveWidgetData<bool>(key, value);
connectDoubleLessonsKey, }
defaultValue: true,
); static Future<bool> _getBool(String key, {required bool defaultValue}) async {
return value ?? true; await ensureInitialized();
return await HomeWidget.getWidgetData<bool>(key) ?? defaultValue;
} }
static Future<void> setThemeMode(String mode) async { static Future<void> setThemeMode(String mode) async {
@@ -90,6 +102,12 @@ class WidgetSync {
return HomeWidget.getWidgetData<String>(marianumConnectBaseUrlKey); 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 { static Future<void> clear() async {
await ensureInitialized(); await ensureInitialized();
await HomeWidget.saveWidgetData<String>(dayDataKey, null); await HomeWidget.saveWidgetData<String>(dayDataKey, null);
@@ -103,11 +121,11 @@ class WidgetSync {
try { try {
await HomeWidget.updateWidget( await HomeWidget.updateWidget(
androidName: androidDayProvider, androidName: androidDayProvider,
iOSName: iosWidgetKind, iOSName: iosDayWidgetKind,
); );
await HomeWidget.updateWidget( await HomeWidget.updateWidget(
androidName: androidWeekProvider, androidName: androidWeekProvider,
iOSName: iosWidgetKind, iOSName: iosWeekWidgetKind,
); );
} on Exception catch (e) { } on Exception catch (e) {
log('WidgetSync.triggerUpdate failed: $e'); log('WidgetSync.triggerUpdate failed: $e');
+3 -5
View File
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
publish_to: 'none' publish_to: 'none'
version: 1.3.0+58 version: 1.5.2+62
environment: environment:
sdk: ">=3.8.0 <4.0.0" sdk: ">=3.8.0 <4.0.0"
@@ -99,15 +99,13 @@ dependencies:
app_settings: ^7.0.0 app_settings: ^7.0.0
flutter_layout_grid: ^2.0.8 flutter_layout_grid: ^2.0.8
flutter_markdown_plus: ^1.0.12 flutter_markdown_plus: ^1.0.12
integration_test:
sdk: flutter
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# Screenshot-Automatisierung für den Play Store: treibt den Demo-Login über # 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 fake_async: ^1.3.1
flutter_launcher_icons: ^0.14.3 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', () { group('pushTokenVariant', () {
test('general uses the raw token, talk appends the suffix', () { test('general uses the raw token, talk appends the suffix', () {
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok'); expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
+41
View File
@@ -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', () { test('subject without signature is not a nextcloud push', () {
expect(classifyPush({'subject': 'enc'}), PushKind.unknown); 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', ( testWidgets('tapping a toolbar button switches the shared mode', (
tester, tester,
) async { ) 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); expect(pmTableMode.value, PmTableMode.scroll);
await tester.tap(find.byTooltip('Spalten umbrechen').first); 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);
});
}
+93 -2
View File
@@ -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', () { group('buildWeekData', () {
final now = DateTime(2026, 5, 5, 10); // Tuesday 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 = [ final lessons = [
_lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'), _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, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'),
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), _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, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
_lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'),
]; ];
final data = WidgetDataMapper.buildWeekData( final data = WidgetDataMapper.buildWeekData(
now: now, now: now,
@@ -365,8 +382,82 @@ void main() {
expect(data.anchorDate, DateTime(2026, 5, 4)); expect(data.anchorDate, DateTime(2026, 5, 4));
expect( expect(
data.lessons.map((l) => l.subjectShort).toList(), 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');
});
}); });
} }