Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
630497abdd | ||
|
|
2423c1a75e | ||
|
|
67c935c05b | ||
|
|
e4e2b1a4fb | ||
|
|
3734d7ff2c | ||
|
|
43dfc52bc7 | ||
|
|
f19f212fb4 | ||
|
|
71839d0848 | ||
|
|
65300614b1 | ||
|
|
02a7b87b5b | ||
|
|
bd83a88e70 | ||
|
|
09d4ecda78 | ||
|
|
d27312afdf | ||
|
|
ccb22a497d | ||
|
|
889d8f67c5 | ||
|
|
39c16bd4ea | ||
|
|
b9cb1df473 | ||
|
|
62fa337188 | ||
|
|
c0dadf8b6e | ||
|
|
ab23422a86 | ||
|
|
646e2c0451 | ||
|
|
246cb0f527 | ||
|
|
4c2e9b47e7 | ||
|
|
2d690736e3 | ||
|
|
778c473631 | ||
|
|
7e9cbcf1e9 | ||
|
|
32be67426c | ||
|
|
b957189fd3 | ||
|
|
624c5512a6 | ||
|
|
3493cf8617 | ||
|
|
75080a2c49 | ||
|
|
f3cd7896d9 | ||
|
|
101e7c909c | ||
|
|
429f1e6f96 | ||
|
|
a0c55a811c | ||
|
|
e625216a90 | ||
|
|
8128cede21 | ||
|
|
e5f7cf0176 | ||
|
|
e349d667d4 | ||
|
|
7c1f5c06df | ||
|
|
e8c6ac1c65 | ||
|
|
478f0ff20b | ||
|
|
8274dd46cd | ||
|
|
d7536ea5d0 | ||
|
|
398b147c76 |
@@ -1,6 +1,6 @@
|
||||
# MarianumMobile Client
|
||||
|
||||
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push).
|
||||
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -16,7 +16,10 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
||||
|
||||
```
|
||||
lib/
|
||||
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
||||
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
|
||||
├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
|
||||
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
|
||||
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
|
||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||
├── view/ Screens
|
||||
@@ -51,6 +54,14 @@ lib/
|
||||
|
||||
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
||||
|
||||
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||
|
||||
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
|
||||
|
||||
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
|
||||
|
||||
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
|
||||
|
||||
## Build / Run
|
||||
|
||||
```bash
|
||||
@@ -65,13 +76,12 @@ flutter test # Tests (siehe test
|
||||
|
||||
| Backend | Pfad | Zweck |
|
||||
|---------------------------|-----------------------|----------------------------------------|
|
||||
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer |
|
||||
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
|
||||
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
||||
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten |
|
||||
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
|
||||
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
||||
|
||||
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
||||
|
||||
## Tests
|
||||
|
||||
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
||||
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
||||
|
||||
@@ -18,6 +18,9 @@ analyzer:
|
||||
- "**/*.freezed.dart"
|
||||
- "lib/firebase_options.dart"
|
||||
- "build/**"
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
.kotlin/
|
||||
|
||||
@@ -33,10 +33,6 @@ android {
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
@@ -47,7 +43,6 @@ android {
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -59,11 +54,20 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.android.support:multidex:2.0.1'
|
||||
// Same version as workmanager_android pins — needed to enqueue its
|
||||
// BackgroundWorker from native widget code (the plugin uses
|
||||
// `implementation`, so androidx.work is not exposed transitively).
|
||||
implementation 'androidx.work:work-runtime:2.11.2'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
}
|
||||
|
||||
@@ -41,6 +41,23 @@
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
<!-- Guardian login mail link (App Link). Verified via
|
||||
/.well-known/assetlinks.json on both hosts. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="connect.marianum-fulda.de" />
|
||||
<data android:host="connect-beta.marianum-fulda.de" />
|
||||
<data android:path="/app/guardian-login" />
|
||||
</intent-filter>
|
||||
<!-- The app routes links itself (GuardianLinkListener); Flutter's
|
||||
built-in handling would push the path as a named route onto a
|
||||
MaterialApp that only has `home`. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Generated file.
|
||||
//
|
||||
// If you wish to remove Flutter's multidex support, delete this entire file.
|
||||
//
|
||||
// Modifications to this file should be done in a copy under a different name
|
||||
// as this file may be regenerated.
|
||||
|
||||
package io.flutter.app;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.multidex.MultiDex;
|
||||
|
||||
/**
|
||||
* Extension of {@link android.app.Application}, adding multidex support.
|
||||
*/
|
||||
public class FlutterMultiDexApplication extends Application {
|
||||
@Override
|
||||
@CallSuper
|
||||
protected void attachBaseContext(Context base) {
|
||||
super.attachBaseContext(base);
|
||||
MultiDex.install(this);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||
*/
|
||||
class TimetableDayWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
class TimetableWeekWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -33,6 +33,8 @@ data class WidgetLesson(
|
||||
val subjectShort: String,
|
||||
val subjectLong: String?,
|
||||
val room: String?,
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is null then) — mapped in Dart.
|
||||
val teacher: String?,
|
||||
val originalTeacher: String?,
|
||||
val status: WidgetLessonStatus,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package eu.mhsl.marianum.mobile.client.widgets
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import dev.fluttercommunity.workmanager.BackgroundWorker
|
||||
|
||||
/**
|
||||
* Enqueues the app's Dart one-off widget refresh from native code, so a
|
||||
* freshly placed widget populates within seconds instead of waiting for the
|
||||
* next periodic slot.
|
||||
*
|
||||
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
|
||||
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
|
||||
* persisted by Workmanager().initialize(); if the app never ran, the worker
|
||||
* fails gracefully and the widget keeps rendering its cached/empty state.
|
||||
*/
|
||||
object WidgetRefreshRequester {
|
||||
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
|
||||
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
|
||||
|
||||
fun enqueueOneOffRefresh(context: Context) {
|
||||
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
|
||||
.build(),
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -736,8 +736,11 @@ object WidgetRenderer {
|
||||
)
|
||||
}
|
||||
|
||||
// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||
// schema bump must land in Dart, Swift (WidgetData.swift) and here
|
||||
// together, or the out-of-sync platform silently renders empty.
|
||||
const val KEY_DAY_DATA = "widget_data_day_v1"
|
||||
const val KEY_WEEK_DATA = "widget_data_week_v1"
|
||||
const val KEY_WEEK_DATA = "widget_data_week_v2"
|
||||
const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
|
||||
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
+18
-3
@@ -5,10 +5,25 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
rootProject.layout.buildDirectory = file('../build')
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
project.layout.buildDirectory = rootProject.layout.buildDirectory.dir(project.name)
|
||||
}
|
||||
// AGP 9 enables built-in Kotlin, but Flutter's migrator pins
|
||||
// android.builtInKotlin=false while the plugin ecosystem catches up. Most
|
||||
// plugins honour that flag and fall back to KGP themselves; file_picker and
|
||||
// background_downloader only check the AGP major version and silently skip
|
||||
// applying KGP, which leaves their Kotlin sources uncompiled (missing
|
||||
// FilePickerPlugin/BackgroundDownloaderPlugin at link time). Apply KGP for
|
||||
// them as soon as AGP is on the project, i.e. before their android {} block
|
||||
// runs. Drop this once both plugins ship a builtInKotlin-aware build script.
|
||||
gradle.beforeProject { sub ->
|
||||
if (!(sub.name in ['file_picker', 'background_downloader'])) return
|
||||
sub.plugins.withId('com.android.library') {
|
||||
sub.pluginManager.apply('org.jetbrains.kotlin.android')
|
||||
}
|
||||
}
|
||||
|
||||
// Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources
|
||||
// compiled with a higher target (e.g. receive_sharing_intent at 21) or stale
|
||||
// Java compatibility (e.g. home_widget at 1.8) don't break the build under
|
||||
@@ -42,5 +57,5 @@ subprojects {
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -19,9 +19,9 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.13.2' apply false
|
||||
id "com.android.library" version '8.13.2' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
|
||||
id "com.android.application" version '9.1.0' apply false
|
||||
id "com.android.library" version '9.1.0' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.4.0" apply false
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
#
|
||||
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
|
||||
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
|
||||
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
|
||||
#
|
||||
# Notfall-Nachricht der MarianumMobile-App
|
||||
#
|
||||
# Diese Datei wird bei jedem App-Start geladen.
|
||||
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
|
||||
#
|
||||
# Steuerfelder:
|
||||
# active: true schaltet die Anzeige ein (Default: false)
|
||||
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
|
||||
# title: optionale Überschrift
|
||||
#
|
||||
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
|
||||
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
|
||||
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
|
||||
# Beispielkonfiguration:
|
||||
#
|
||||
# ---
|
||||
# # Ein Kommentar
|
||||
# active: true
|
||||
# dismissible: false
|
||||
# title: Wichtiger Hinweis
|
||||
# ---
|
||||
# Hinweistext in Markdown
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
active: false
|
||||
dismissible: true
|
||||
title: Hinweis
|
||||
---
|
||||
# Serverstörung
|
||||
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
|
||||
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
|
||||
|
||||
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
|
||||
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
|
||||
Future<void> _login(WidgetTester tester) async {
|
||||
final loginVisible = await _pumpUntil(
|
||||
tester,
|
||||
find.byKey(const Key('login-username-field')),
|
||||
find.byKey(const Key('login-audience-school')),
|
||||
);
|
||||
if (!loginVisible) {
|
||||
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
||||
return;
|
||||
}
|
||||
await tester.tap(find.byKey(const Key('login-audience-school')));
|
||||
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('login-username-field')),
|
||||
'demo@screenshots',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -31,7 +31,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
||||
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
||||
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
||||
private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||
|
||||
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
|
||||
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
||||
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
/// kSecClass = kSecClassGenericPassword
|
||||
/// kSecAttrAccount = the Dart key, verbatim
|
||||
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
||||
/// kSecAttrAccessGroup = the App Group id
|
||||
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
|
||||
/// value = raw UTF-8 bytes of the string
|
||||
private func keychainString(_ account: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+36
-10
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
|
||||
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
||||
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
||||
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
||||
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
|
||||
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
|
||||
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
|
||||
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
|
||||
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
|
||||
|
||||
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
||||
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
||||
@@ -67,6 +68,15 @@ iOS zeigt die fertige Notification
|
||||
|
||||
## 3. Xcode-Checkliste (auf dem Mac)
|
||||
|
||||
> **Stand 2026-07-07:** Abschnitte 3.1–3.2 (Target anlegen, Dateien zuordnen) sind
|
||||
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
|
||||
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
|
||||
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
|
||||
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.3–3.4, 3.6)** —
|
||||
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
|
||||
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
|
||||
> aufgesetzt werden muss).
|
||||
|
||||
### 3.1 Target anlegen
|
||||
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
||||
2. **File → New → Target… → iOS → Notification Service Extension**.
|
||||
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
|
||||
## 4. Ermittelte Keychain-Details (verbindlich)
|
||||
|
||||
Die Dart-Seite schreibt mit
|
||||
`IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`.
|
||||
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
|
||||
|
||||
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
|
||||
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
|
||||
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
|
||||
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
|
||||
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
|
||||
> Automatic Signing mit „doesn't match the entitlements file's value for the
|
||||
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
|
||||
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
|
||||
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
|
||||
|
||||
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
||||
|
||||
@@ -141,7 +162,7 @@ Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||
| `kSecClass` | `kSecClassGenericPassword` |
|
||||
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
||||
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
||||
| `kSecAttrAccessGroup` | `group.eu.mhsl.marianum.mobile.client.widget` |
|
||||
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
|
||||
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
||||
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
||||
|
||||
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
|
||||
innerhalb des NSE-Budgets). Nicht implementiert.
|
||||
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
||||
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
||||
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
|
||||
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
|
||||
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
|
||||
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
|
||||
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
|
||||
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
|
||||
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
|
||||
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
|
||||
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
|
||||
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
|
||||
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
|
||||
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
|
||||
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
|
||||
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
|
||||
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
|
||||
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
|
||||
vollqualifizierten Group-String verwenden.
|
||||
|
||||
@@ -4,8 +4,6 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_app_badge (2.0.0):
|
||||
- Flutter
|
||||
- home_widget (0.0.1):
|
||||
- Flutter
|
||||
- open_filex (0.0.2):
|
||||
- Flutter
|
||||
- PhoneNumberKit (3.7.11):
|
||||
@@ -14,8 +12,6 @@ PODS:
|
||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||
- PhoneNumberKit/UIKit (3.7.11):
|
||||
- PhoneNumberKit/PhoneNumberKitCore
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- workmanager_apple (0.0.1):
|
||||
- Flutter
|
||||
|
||||
@@ -23,10 +19,8 @@ DEPENDENCIES:
|
||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||
- PhoneNumberKit (~> 3.7.6)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -40,12 +34,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_app_badge:
|
||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||
home_widget:
|
||||
:path: ".symlinks/plugins/home_widget/ios"
|
||||
open_filex:
|
||||
:path: ".symlinks/plugins/open_filex/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
workmanager_apple:
|
||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||
|
||||
@@ -53,10 +43,8 @@ SPEC CHECKSUMS:
|
||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||
|
||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||
|
||||
@@ -12,14 +12,18 @@
|
||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
|
||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
||||
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -37,6 +41,13 @@
|
||||
remoteGlobalIDString = AA0101010000000011111111;
|
||||
remoteInfo = TimetableWidgetExtension;
|
||||
};
|
||||
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
|
||||
remoteInfo = NotificationServiceExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -48,6 +59,7 @@
|
||||
files = (
|
||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
||||
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -65,17 +77,24 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
|
||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
||||
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -94,10 +113,12 @@
|
||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -169,6 +190,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -185,15 +214,36 @@
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
|
||||
509DCCD474353408FE5806C5 /* PEM.swift */,
|
||||
12B96C78930441C73F123636 /* Info.plist */,
|
||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||
);
|
||||
name = NotificationServiceExtension;
|
||||
path = NotificationServiceExtension;
|
||||
sourceTree = SOURCE_ROOT;
|
||||
};
|
||||
731388A08E3B330B216381D0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
||||
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -208,6 +258,9 @@
|
||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
||||
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
|
||||
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
|
||||
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
@@ -222,6 +275,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
345F4BD4143471FDA71626DE /* Pods */,
|
||||
731388A08E3B330B216381D0 /* Frameworks */,
|
||||
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -231,6 +285,7 @@
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
||||
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -278,9 +333,6 @@
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -299,8 +351,12 @@
|
||||
dependencies = (
|
||||
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
||||
AA0101090000000011111111 /* PBXTargetDependency */,
|
||||
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -325,13 +381,27 @@
|
||||
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
|
||||
buildPhases = (
|
||||
DF8C1170E7DF96711030EAC0 /* Sources */,
|
||||
D2447D92E9CCD96B3292B17B /* Frameworks */,
|
||||
239068341DC6E6B36193EC96 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = NotificationServiceExtension;
|
||||
productName = NotificationServiceExtension;
|
||||
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
@@ -360,6 +430,9 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -367,11 +440,19 @@
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
3321F8042FB1C00C0011C712 /* Share Extension */,
|
||||
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
||||
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
239068341DC6E6B36193EC96 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3321F8032FB1C00C0011C712 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -520,6 +601,15 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DF8C1170E7DF96711030EAC0 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
|
||||
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@@ -528,6 +618,12 @@
|
||||
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
||||
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
||||
};
|
||||
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = NotificationServiceExtension;
|
||||
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
|
||||
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
|
||||
};
|
||||
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
||||
@@ -929,6 +1025,29 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A2B046F78AD55232F08583F3 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA01010A0000000011111111 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
||||
@@ -1051,6 +1170,54 @@
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
E5EE58583E83B7694F6C3C5E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F418DF43742DC2E30B161652 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1094,13 +1261,25 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E5EE58583E83B7694F6C3C5E /* Release */,
|
||||
A2B046F78AD55232F08583F3 /* Debug */,
|
||||
F418DF43742DC2E30B161652 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
||||
"version" : "11.2.0"
|
||||
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
||||
"version" : "11.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -50,8 +50,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
|
||||
"version" : "12.13.0"
|
||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||
"version" : "12.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,8 +59,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
|
||||
"version" : "3.5.0"
|
||||
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
||||
"version" : "3.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,8 +68,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
|
||||
"version" : "12.13.0"
|
||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||
"version" : "12.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
||||
"version" : "8.1.0"
|
||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||
"version" : "8.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -122,8 +122,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
||||
"version" : "1.22.2"
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -131,8 +131,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
||||
"version" : "2.30910.0"
|
||||
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||
"version" : "2.30910.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -140,8 +140,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import workmanager_apple
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@@ -12,7 +13,7 @@ import UserNotifications
|
||||
private let markReadActionId = "TALK_MARK_READ"
|
||||
|
||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
||||
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||
private let usernameAccount = "nextcloud_username"
|
||||
private let appPasswordAccount = "nextcloud_app_password"
|
||||
private let baseUrlAccount = "nextcloud_base_url"
|
||||
@@ -23,6 +24,19 @@ import UserNotifications
|
||||
) -> Bool {
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
registerTalkCategory()
|
||||
// BGAppRefresh for the home-screen widget. Must all happen before
|
||||
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
|
||||
// plugins only during scene connection, which is past BGTaskScheduler's
|
||||
// registration deadline — registerLaunchHandlers() bridges that gap.
|
||||
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
|
||||
// plugin re-submits the refresh request itself after every run.
|
||||
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
WorkmanagerPlugin.registerPeriodicTask(
|
||||
withIdentifier: "eu.mhsl.marianum.widget.refresh",
|
||||
earliestBeginInSeconds: 1800)
|
||||
WorkmanagerPlugin.registerLaunchHandlers()
|
||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||
// forwards these callbacks to the plugins (firebase_messaging,
|
||||
// flutter_local_notifications). We route Talk actions natively here — the
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
@@ -66,6 +68,10 @@
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:connect.marianum-fulda.de</string>
|
||||
<string>applinks:connect-beta.marianum-fulda.de</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
@@ -11,7 +16,7 @@
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
for context in connectionOptions.urlContexts {
|
||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
||||
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||
UIApplication.shared,
|
||||
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
||||
)
|
||||
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
for context in URLContexts {
|
||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
||||
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||
UIApplication.shared,
|
||||
open: context.url,
|
||||
options: [:]
|
||||
|
||||
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
|
||||
import AVFoundation
|
||||
|
||||
// Datenmodell muss byte-für-byte zu dem passen, was
|
||||
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
private enum SharedMediaType: String, Codable {
|
||||
case image, video, text, file, url
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart`
|
||||
/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync — the Dart
|
||||
/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the
|
||||
/// review checklist here:
|
||||
/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff)
|
||||
/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff)
|
||||
/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri → Mon)
|
||||
/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11
|
||||
/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11
|
||||
/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04
|
||||
/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11
|
||||
enum TimetableAnchor {
|
||||
/// After 17:00 the user's question shifts from "what's left today" to
|
||||
/// "what's tomorrow", so the day widget rolls forward.
|
||||
static let dayCutoffHour = 17
|
||||
|
||||
static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||
var candidate = calendar.startOfDay(for: now)
|
||||
let shiftToTomorrow =
|
||||
calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar)
|
||||
if shiftToTomorrow {
|
||||
candidate = nextDay(candidate, calendar: calendar)
|
||||
}
|
||||
while isWeekend(candidate, calendar: calendar) {
|
||||
candidate = nextDay(candidate, calendar: calendar)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||
let anchor = resolveDayAnchor(now, calendar: calendar)
|
||||
// Swift weekday: 1 = Sunday … 7 = Saturday → distance back to Monday.
|
||||
let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7
|
||||
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor
|
||||
return calendar.startOfDay(for: monday)
|
||||
}
|
||||
|
||||
/// Future instants at which the rendered anchor can change: each day's
|
||||
/// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted,
|
||||
/// strictly after `now`.
|
||||
static func boundaryDates(
|
||||
from now: Date,
|
||||
horizonDays: Int = 3,
|
||||
calendar: Calendar = .current
|
||||
) -> [Date] {
|
||||
var result: [Date] = []
|
||||
let today = calendar.startOfDay(for: now)
|
||||
for offset in 0...horizonDays {
|
||||
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
|
||||
let midnight = calendar.startOfDay(for: day)
|
||||
if midnight > now { result.append(midnight) }
|
||||
if let cutoff = calendar.date(
|
||||
bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day
|
||||
), cutoff > now {
|
||||
result.append(cutoff)
|
||||
}
|
||||
}
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
/// Derives a day payload from the 14-day week payload — same shape the
|
||||
/// Dart buildDayData produces, since the week payload runs through the
|
||||
/// identical per-day merge/collision pipeline.
|
||||
static func slice(
|
||||
week: WidgetTimetableData,
|
||||
forDay anchor: Date,
|
||||
calendar: Calendar = .current
|
||||
) -> WidgetTimetableData {
|
||||
let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) }
|
||||
// Every v2 week payload carries `days` — the key bump guarantees it.
|
||||
let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) }
|
||||
return WidgetTimetableData(
|
||||
fetchedAt: week.fetchedAt,
|
||||
anchorDate: anchor,
|
||||
lessons: lessons,
|
||||
periods: week.periods,
|
||||
isHoliday: dayInfo?.isHoliday ?? false,
|
||||
holidayName: dayInfo?.holidayName,
|
||||
days: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Week payload re-anchored to the week containing/following `anchorDate`.
|
||||
/// The week view filters its five columns off `anchorDate`, so this alone
|
||||
/// performs the Friday-evening/weekend jump to next week.
|
||||
static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData {
|
||||
WidgetTimetableData(
|
||||
fetchedAt: week.fetchedAt,
|
||||
anchorDate: anchorDate,
|
||||
lessons: week.lessons,
|
||||
periods: week.periods,
|
||||
isHoliday: week.isHoliday,
|
||||
holidayName: week.holidayName,
|
||||
days: week.days
|
||||
)
|
||||
}
|
||||
|
||||
private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool {
|
||||
let weekday = calendar.component(.weekday, from: date)
|
||||
return weekday == 1 || weekday == 7
|
||||
}
|
||||
|
||||
private static func nextDay(_ date: Date, calendar: Calendar) -> Date {
|
||||
let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date
|
||||
return calendar.startOfDay(for: next)
|
||||
}
|
||||
}
|
||||
@@ -100,13 +100,13 @@ struct TimetableDayView: View {
|
||||
|
||||
private func header(data: WidgetTimetableData) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(dayLabel(for: data.anchorDate))
|
||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 4)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -383,6 +383,7 @@ struct TimeGridView: View {
|
||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
||||
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,9 +401,31 @@ func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
func dayLabel(for date: Date) -> String {
|
||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||
/// expensive part and the multi-entry timelines render up to ~8 entries per
|
||||
/// reload. Only touched from WidgetKit's archival rendering, so the shared
|
||||
/// instances are safe.
|
||||
enum WidgetDateFormatters {
|
||||
static let shortDate = make("dd.MM.")
|
||||
static let weekdayShort = make("EE")
|
||||
static let weekdayDate = make("EEEE · dd.MM.")
|
||||
static let time = make("HH:mm")
|
||||
static let dateTime = make("dd.MM. HH:mm")
|
||||
|
||||
private static func make(_ format: String) -> DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = format
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
|
||||
/// entries ahead of time, so wall-clock reads would be wrong for every
|
||||
/// entry after the first.
|
||||
func dayLabel(for date: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let anchor = cal.startOfDay(for: date)
|
||||
if anchor == today {
|
||||
return "Heute · \(shortDate(date))"
|
||||
@@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String {
|
||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||
return "Morgen · \(shortDate(date))"
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.dateFormat = "EEEE · dd.MM."
|
||||
return formatter.string(from: date)
|
||||
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||
}
|
||||
|
||||
func shortDate(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "dd.MM."
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.shortDate.string(from: date)
|
||||
}
|
||||
|
||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
||||
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||
let timeFmt = DateFormatter()
|
||||
timeFmt.locale = Locale(identifier: "de_DE")
|
||||
timeFmt.dateFormat = "HH:mm"
|
||||
if fetchedDay == today {
|
||||
return timeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.time.string(from: fetchedAt)
|
||||
}
|
||||
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||
fetchedDay == yesterday {
|
||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
||||
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||
}
|
||||
let dateTimeFmt = DateFormatter()
|
||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
||||
return dateTimeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Spacer()
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
|
||||
}
|
||||
|
||||
private func weekday(for date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "EE"
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.weekdayShort.string(from: date)
|
||||
}
|
||||
|
||||
private func placeholder(_ message: String) -> some View {
|
||||
|
||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .day)
|
||||
let now = Date()
|
||||
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
||||
// advisory; the "Stand:" label tells the user when data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
// advisory; the boundary entries below keep the rendered day correct
|
||||
// even when no reload is granted, and the "Stand:" label tells the
|
||||
// user when the underlying data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .week)
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
let now = Date()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
||||
themeMode: WidgetDataLoader.themeMode()
|
||||
)
|
||||
}
|
||||
|
||||
/// Day timeline derived from the 14-day week payload, so the widget shows
|
||||
/// the right day even when iOS grants no reload for days.
|
||||
static func dayEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .day) { week, date in
|
||||
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
|
||||
/// into next week from cached data, and the midnight entries keep the
|
||||
/// "Stand:" freshness label honest.
|
||||
static func weekEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .week) { week, date in
|
||||
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared timeline skeleton: one entry now plus one per anchor boundary
|
||||
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
|
||||
/// render — same anchor and same calendar day for the header labels —
|
||||
/// are dropped.
|
||||
private static func entries(
|
||||
now: Date,
|
||||
variant: TimetableVariant,
|
||||
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
|
||||
) -> [TimetableEntry] {
|
||||
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
|
||||
// Logged out, or no v2 week snapshot yet (fresh app update):
|
||||
// fall back to the legacy single-entry payload.
|
||||
return [TimetableEntry.current(variant: variant)]
|
||||
}
|
||||
let theme = WidgetDataLoader.themeMode()
|
||||
let cal = Calendar.current
|
||||
var result: [TimetableEntry] = []
|
||||
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
|
||||
let data = transform(week, date)
|
||||
if let previous = result.last, let previousData = previous.data,
|
||||
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
|
||||
cal.isDate(previous.date, inSameDayAs: date) {
|
||||
continue
|
||||
}
|
||||
result.append(TimetableEntry(
|
||||
date: date,
|
||||
variant: variant,
|
||||
data: data,
|
||||
isLoggedIn: true,
|
||||
themeMode: theme
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
||||
case irregular
|
||||
case teacherChanged
|
||||
case event
|
||||
case duty
|
||||
|
||||
/// Unknown future statuses degrade to `.regular` instead of failing the
|
||||
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
|
||||
/// single new enum value must never blank the widget to the placeholder.
|
||||
init(from decoder: Decoder) throws {
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
self = WidgetLessonStatus(rawValue: raw) ?? .regular
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetLesson: Codable {
|
||||
@@ -18,6 +27,8 @@ struct WidgetLesson: Codable {
|
||||
let subjectShort: String
|
||||
let subjectLong: String?
|
||||
let room: String?
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is nil then) — mapped in Dart.
|
||||
let teacher: String?
|
||||
let originalTeacher: String?
|
||||
let status: WidgetLessonStatus
|
||||
@@ -33,6 +44,12 @@ struct WidgetPeriod: Codable {
|
||||
let virtualEndMinutes: Int
|
||||
}
|
||||
|
||||
struct WidgetDayInfo: Codable {
|
||||
let date: Date
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
}
|
||||
|
||||
struct WidgetTimetableData: Codable {
|
||||
let fetchedAt: Date
|
||||
let anchorDate: Date
|
||||
@@ -40,12 +57,17 @@ struct WidgetTimetableData: Codable {
|
||||
let periods: [WidgetPeriod]
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
/// Week payload (v2) only; optional so day payloads keep decoding.
|
||||
let days: [WidgetDayInfo]?
|
||||
}
|
||||
|
||||
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
|
||||
/// together, or the out-of-sync platform silently blanks to the placeholder.
|
||||
enum WidgetDataKey {
|
||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
static let dayData = "widget_data_day_v1"
|
||||
static let weekData = "widget_data_week_v1"
|
||||
static let weekData = "widget_data_week_v2"
|
||||
static let loggedIn = "widget_data_logged_in_v1"
|
||||
static let themeMode = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../session/session.dart';
|
||||
|
||||
/// Backend identity a feature needs. Modules and settings sections declare
|
||||
/// these; anything whose requirements the session does not meet is hidden.
|
||||
enum AccessRequirement {
|
||||
nextcloud,
|
||||
guardian;
|
||||
|
||||
bool isMetBy(Session? session) => switch (this) {
|
||||
AccessRequirement.nextcloud => session?.nextcloud != null,
|
||||
AccessRequirement.guardian => session is GuardianSession,
|
||||
};
|
||||
}
|
||||
|
||||
extension AccessRequirements on Set<AccessRequirement> {
|
||||
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Account role as reported by MarianumConnect. Only used for display and for
|
||||
/// deriving policies; features gate on capabilities and requirements instead
|
||||
/// of comparing roles.
|
||||
enum UserRole {
|
||||
student,
|
||||
teacher,
|
||||
staff,
|
||||
parent,
|
||||
unknown;
|
||||
|
||||
/// Unknown or missing values map to [unknown] so a new server-side role
|
||||
/// never breaks older app versions.
|
||||
static UserRole parse(String? wire) => switch (wire) {
|
||||
'STUDENT' => UserRole.student,
|
||||
'TEACHER' => UserRole.teacher,
|
||||
'STAFF' => UserRole.staff,
|
||||
'PARENT' => UserRole.parent,
|
||||
_ => UserRole.unknown,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
class ApiRequest {}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||
/// demo persona sees every feature (incl. push) as available.
|
||||
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||
/// scroll range stays unlimited (null day counts).
|
||||
class DemoCapabilities {
|
||||
const DemoCapabilities._();
|
||||
|
||||
static CapabilitiesState state() => const CapabilitiesState(
|
||||
viewForeignTimetables: true,
|
||||
pushNotifications: true,
|
||||
timetablePastDays: null,
|
||||
timetableFutureDays: null,
|
||||
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
|
||||
// Feld nicht" (siehe CapabilitiesResponse.userType).
|
||||
userType: 'STUDENT',
|
||||
loaded: true,
|
||||
);
|
||||
|
||||
/// Guardian persona with two children, so the child switcher is visible.
|
||||
static CapabilitiesState guardianState() => const CapabilitiesState(
|
||||
pushNotifications: true,
|
||||
userType: 'PARENT',
|
||||
children: [
|
||||
GuardianChild(
|
||||
id: 'demo-child-1',
|
||||
firstName: DemoPersona.studentFirstName,
|
||||
lastName: 'Hoffmann',
|
||||
className: DemoPersona.className,
|
||||
),
|
||||
GuardianChild(
|
||||
id: 'demo-child-2',
|
||||
firstName: 'Jonas',
|
||||
lastName: 'Hoffmann',
|
||||
className: '6a',
|
||||
),
|
||||
],
|
||||
loaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Demo inbox of the guardian persona: one letter per variant (information,
|
||||
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
|
||||
/// Child ids match [DemoCapabilities.guardianState].
|
||||
class DemoParentLetters {
|
||||
const DemoParentLetters._();
|
||||
|
||||
static const _lena = 'demo-child-1';
|
||||
static const _jonas = 'demo-child-2';
|
||||
|
||||
static final Set<String> _readInSession = {};
|
||||
|
||||
static void markRead(String letterId) => _readInSession.add(letterId);
|
||||
|
||||
static ParentLetterListResponse list() {
|
||||
final items = [for (final letter in _letters()) letter.summary];
|
||||
return ParentLetterListResponse(
|
||||
items: items,
|
||||
unreadCount: items.where((letter) => !letter.read).length,
|
||||
openCount: items
|
||||
.where((letter) => letter.status == ParentLetterStatus.open)
|
||||
.length,
|
||||
);
|
||||
}
|
||||
|
||||
static ParentLetterDetail detail(String letterId) {
|
||||
final letters = _letters();
|
||||
return letters.firstWhere(
|
||||
(letter) => letter.summary.id == letterId,
|
||||
orElse: () => letters.first,
|
||||
);
|
||||
}
|
||||
|
||||
static ParentLetterPerson _teacher(int index) =>
|
||||
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
|
||||
|
||||
static const _participation = ParentLetterField(
|
||||
id: 'participation',
|
||||
type: ParentLetterFieldType.singleChoice,
|
||||
label: 'Nimmt Ihr Kind teil?',
|
||||
isRequired: true,
|
||||
options: [
|
||||
ParentLetterOption(id: 'yes', label: 'Ja'),
|
||||
ParentLetterOption(id: 'no', label: 'Nein'),
|
||||
],
|
||||
);
|
||||
|
||||
static List<ParentLetterDetail> _letters() {
|
||||
final now = DateTime.now();
|
||||
ParentLetterDetail letter({
|
||||
required String id,
|
||||
required String subject,
|
||||
required ParentLetterPerson sender,
|
||||
required Duration age,
|
||||
required String body,
|
||||
required List<String> childIds,
|
||||
bool read = true,
|
||||
ParentLetterStatus status = ParentLetterStatus.info,
|
||||
ParentLetterRequest? request,
|
||||
List<ParentLetterChildState> children = const [],
|
||||
List<ParentLetterAttachment> attachments = const [],
|
||||
ParentLetterThread thread = const ParentLetterThread(enabled: true),
|
||||
}) => ParentLetterDetail(
|
||||
summary: ParentLetterSummary(
|
||||
id: id,
|
||||
subject: subject,
|
||||
preview: body.replaceAll('\n', ' ').trim(),
|
||||
sender: sender,
|
||||
sentAt: now.subtract(age),
|
||||
read: read || _readInSession.contains(id),
|
||||
childIds: childIds,
|
||||
attachmentCount: attachments.length,
|
||||
status: status,
|
||||
deadline: request?.deadline,
|
||||
),
|
||||
content: ParentLetterContent(
|
||||
body: body,
|
||||
attachments: attachments,
|
||||
request: request,
|
||||
children: children,
|
||||
thread: thread,
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
letter(
|
||||
id: 'demo-letter-hike',
|
||||
subject: 'Wandertag der ${DemoPersona.className}',
|
||||
sender: _teacher(0),
|
||||
age: const Duration(hours: 2),
|
||||
read: false,
|
||||
childIds: const [_lena],
|
||||
status: ParentLetterStatus.open,
|
||||
body:
|
||||
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
|
||||
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
|
||||
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
|
||||
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
|
||||
'Viele Grüße\n${DemoPersona.teachers[0].name}',
|
||||
attachments: const [
|
||||
ParentLetterAttachment(
|
||||
id: 'demo-attachment-hike',
|
||||
fileName: 'Wandertag.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 48211,
|
||||
),
|
||||
],
|
||||
request: ParentLetterRequest(
|
||||
fields: const [_participation],
|
||||
signatureRequired: true,
|
||||
deadline: now.add(const Duration(days: 10)),
|
||||
),
|
||||
children: const [
|
||||
ParentLetterChildState(
|
||||
childId: _lena,
|
||||
status: ParentLetterStatus.open,
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-parents-evening',
|
||||
subject: 'Einladung zum Elternabend der 6a',
|
||||
sender: _teacher(1),
|
||||
age: const Duration(days: 1),
|
||||
read: false,
|
||||
childIds: const [_jonas],
|
||||
status: ParentLetterStatus.open,
|
||||
body:
|
||||
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
|
||||
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
|
||||
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
|
||||
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
|
||||
request: const ParentLetterRequest(),
|
||||
children: const [
|
||||
ParentLetterChildState(
|
||||
childId: _jonas,
|
||||
status: ParentLetterStatus.open,
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-school-festival',
|
||||
subject: 'Schulfest: Wer kommt mit?',
|
||||
sender: _teacher(5),
|
||||
age: const Duration(days: 3),
|
||||
childIds: const [_lena, _jonas],
|
||||
status: ParentLetterStatus.open,
|
||||
body:
|
||||
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
|
||||
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
|
||||
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
|
||||
'https://www.marianum-fulda.de',
|
||||
request: ParentLetterRequest(
|
||||
fields: const [_participation],
|
||||
deadline: now.add(const Duration(days: 5)),
|
||||
),
|
||||
children: [
|
||||
ParentLetterChildState(
|
||||
childId: _lena,
|
||||
status: ParentLetterStatus.done,
|
||||
editable: true,
|
||||
response: ParentLetterResponse(
|
||||
respondedAt: now.subtract(const Duration(days: 2)),
|
||||
respondedBy: const ParentLetterPerson(self: true),
|
||||
answers: const [
|
||||
ParentLetterAnswer(
|
||||
fieldId: 'participation',
|
||||
optionIds: ['yes'],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ParentLetterChildState(
|
||||
childId: _jonas,
|
||||
status: ParentLetterStatus.open,
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-reports',
|
||||
subject: 'Zeugnisausgabe und Unterrichtsschluss',
|
||||
sender: _teacher(3),
|
||||
age: const Duration(days: 7),
|
||||
childIds: const [_lena, _jonas],
|
||||
body:
|
||||
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
|
||||
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
|
||||
'Busse fahren entsprechend früher.',
|
||||
thread: ParentLetterThread(
|
||||
enabled: true,
|
||||
messages: [
|
||||
ParentLetterThreadMessage(
|
||||
id: 'demo-thread-1',
|
||||
author: const ParentLetterPerson(self: true),
|
||||
body: 'Gilt das auch für die Nachmittagsbetreuung?',
|
||||
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
|
||||
),
|
||||
ParentLetterThreadMessage(
|
||||
id: 'demo-thread-2',
|
||||
author: _teacher(3),
|
||||
body:
|
||||
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
|
||||
'Bedarf melden Sie sich bitte im Sekretariat.',
|
||||
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-swimming',
|
||||
subject: 'Einverständnis Schwimmunterricht',
|
||||
sender: _teacher(6),
|
||||
age: const Duration(days: 21),
|
||||
childIds: const [_jonas],
|
||||
status: ParentLetterStatus.done,
|
||||
body:
|
||||
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
|
||||
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
|
||||
'Einverständnis.',
|
||||
request: const ParentLetterRequest(
|
||||
fields: [_participation],
|
||||
signatureRequired: true,
|
||||
),
|
||||
children: [
|
||||
ParentLetterChildState(
|
||||
childId: _jonas,
|
||||
status: ParentLetterStatus.done,
|
||||
response: ParentLetterResponse(
|
||||
respondedAt: now.subtract(const Duration(days: 20)),
|
||||
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
|
||||
answers: const [
|
||||
ParentLetterAnswer(
|
||||
fieldId: 'participation',
|
||||
optionIds: ['yes'],
|
||||
),
|
||||
],
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
thread: const ParentLetterThread(),
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-ski-trip',
|
||||
subject: 'Anmeldung zur Skifreizeit',
|
||||
sender: _teacher(6),
|
||||
age: const Duration(days: 35),
|
||||
childIds: const [_lena],
|
||||
status: ParentLetterStatus.expired,
|
||||
body:
|
||||
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
|
||||
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
|
||||
request: ParentLetterRequest(
|
||||
fields: const [_participation],
|
||||
signatureRequired: true,
|
||||
deadline: now.subtract(const Duration(days: 14)),
|
||||
),
|
||||
children: const [
|
||||
ParentLetterChildState(
|
||||
childId: _lena,
|
||||
status: ParentLetterStatus.expired,
|
||||
),
|
||||
],
|
||||
thread: const ParentLetterThread(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../demo_persona.dart';
|
||||
@@ -204,7 +204,8 @@ class DemoTalk {
|
||||
id: base + 2,
|
||||
token: token,
|
||||
ago: const Duration(days: 1, hours: 6),
|
||||
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||
message:
|
||||
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 3,
|
||||
@@ -324,6 +325,22 @@ class DemoTalk {
|
||||
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
|
||||
reactions: {'👍': 3},
|
||||
),
|
||||
_msg(
|
||||
id: base + 4,
|
||||
token: token,
|
||||
actor: 'ben',
|
||||
display: 'Ben',
|
||||
ago: const Duration(hours: 2),
|
||||
message:
|
||||
'## Ablauf Sommerfest\n'
|
||||
'Ein paar **Punkte** für Mittwoch:\n\n'
|
||||
'- Stände aufbauen ab *14 Uhr*\n'
|
||||
'- [x] Getränke bestellt\n'
|
||||
'- [ ] Musik organisieren\n\n'
|
||||
'> Bitte bringt Ideen mit!\n\n'
|
||||
'Mehr Infos auf der [SV-Seite](https://marianum-fulda.de).',
|
||||
markdown: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -346,14 +363,16 @@ class DemoTalk {
|
||||
required Duration ago,
|
||||
required String message,
|
||||
Map<String, int>? reactions,
|
||||
bool markdown = false,
|
||||
}) => _msg(
|
||||
id: id,
|
||||
token: token,
|
||||
actor: AccountData().getUsername(),
|
||||
actor: SessionManager().requireNextcloud().username,
|
||||
display: DemoPersona.studentName,
|
||||
ago: ago,
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
markdown: markdown,
|
||||
);
|
||||
|
||||
static GetChatResponseObject _msg({
|
||||
@@ -364,6 +383,7 @@ class DemoTalk {
|
||||
required Duration ago,
|
||||
required String message,
|
||||
Map<String, int>? reactions,
|
||||
bool markdown = false,
|
||||
}) => GetChatResponseObject(
|
||||
id,
|
||||
token,
|
||||
@@ -380,6 +400,7 @@ class DemoTalk {
|
||||
reactions,
|
||||
null,
|
||||
null,
|
||||
markdown,
|
||||
);
|
||||
|
||||
static GetRoomResponseObject _room({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'data/demo_absence.dart';
|
||||
import 'data/demo_breaker.dart';
|
||||
import 'data/demo_holidays.dart';
|
||||
import 'data/demo_timetable.dart';
|
||||
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
|
||||
.toList();
|
||||
case 'breaker':
|
||||
return DemoBreaker.none().toJson();
|
||||
case 'absence/classes':
|
||||
return DemoAbsence.classes();
|
||||
case 'absence/prefill':
|
||||
return DemoAbsence.prefill().toJson();
|
||||
case 'timetable/elements/teachers':
|
||||
case 'timetable/elements/students':
|
||||
case 'timetable/elements/classes':
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
|
||||
/// Central switch for the client-side demo mode.
|
||||
///
|
||||
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
|
||||
/// Play reviewers and automated screenshot runs see a fully populated app
|
||||
/// without a real account and without any network dependency.
|
||||
///
|
||||
/// The flag is persisted through [AccountData.isDemo], so a demo session
|
||||
/// The flag is persisted through [Session.isDemo], so a demo session
|
||||
/// survives cold starts exactly like a normal login. It works in release builds
|
||||
/// too — reviewers run the shipped release build.
|
||||
class DemoMode {
|
||||
@@ -22,6 +22,14 @@ class DemoMode {
|
||||
static bool matches(String username) =>
|
||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||
|
||||
/// Guardian logins are passwordless, so reviewers need a fixed address that
|
||||
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
|
||||
/// real e-mail address could start with.
|
||||
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
|
||||
|
||||
static bool matchesGuardian(String email) =>
|
||||
email.trim().toLowerCase() == guardianEmail;
|
||||
|
||||
/// True while the active session is a demo session.
|
||||
static bool get active => AccountData().isDemo;
|
||||
static bool get active => SessionManager().isDemo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/// A backend-independent emergency notice loaded from a foreign server URL —
|
||||
/// frontmatter (control fields) plus a free Markdown body, so it stays
|
||||
/// hand-writable in an outage. See [parse] for the format.
|
||||
class EmergencyNotice {
|
||||
/// `false` renders full-screen and blocks back/barrier taps.
|
||||
final bool dismissible;
|
||||
final String? title;
|
||||
final String body;
|
||||
|
||||
const EmergencyNotice({
|
||||
required this.dismissible,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
/// Parses the raw file, or returns `null` when there is nothing to show.
|
||||
/// Never throws — malformed input yields `null` so a broken file can't break
|
||||
/// the app.
|
||||
///
|
||||
/// ```
|
||||
/// ---
|
||||
/// active: true # required truthy, else null; # lines are comments
|
||||
/// dismissible: true # default true
|
||||
/// title: Störung # optional
|
||||
/// ---
|
||||
/// Free **markdown** body (everything after the closing ---).
|
||||
/// ```
|
||||
static EmergencyNotice? parse(String raw) {
|
||||
final lines = raw
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.split('\n');
|
||||
|
||||
var i = 0;
|
||||
while (i < lines.length && lines[i].trim().isEmpty) {
|
||||
i++;
|
||||
}
|
||||
if (i >= lines.length || lines[i].trim() != '---') return null;
|
||||
final openIndex = i;
|
||||
|
||||
var closeIndex = -1;
|
||||
for (var j = openIndex + 1; j < lines.length; j++) {
|
||||
if (lines[j].trim() == '---') {
|
||||
closeIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closeIndex == -1) return null;
|
||||
|
||||
final meta = <String, String>{};
|
||||
for (var j = openIndex + 1; j < closeIndex; j++) {
|
||||
final line = lines[j].trim();
|
||||
if (line.isEmpty || line.startsWith('#')) continue;
|
||||
final sep = line.indexOf(':');
|
||||
if (sep <= 0) continue;
|
||||
final key = line.substring(0, sep).trim().toLowerCase();
|
||||
final value = line.substring(sep + 1).trim();
|
||||
meta[key] = value;
|
||||
}
|
||||
|
||||
if (_parseBool(meta['active']) != true) return null;
|
||||
|
||||
final body = lines.sublist(closeIndex + 1).join('\n').trim();
|
||||
if (body.isEmpty) return null;
|
||||
|
||||
final title = meta['title'];
|
||||
return EmergencyNotice(
|
||||
dismissible: _parseBool(meta['dismissible']) ?? true,
|
||||
title: (title == null || title.isEmpty) ? null : title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
static bool? _parseBool(String? value) {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case '1':
|
||||
case 'on':
|
||||
return true;
|
||||
case 'false':
|
||||
case 'no':
|
||||
case '0':
|
||||
case 'off':
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'emergency_notice.dart';
|
||||
|
||||
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
|
||||
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
|
||||
/// Never throws: any failure yields `null` (nothing shown).
|
||||
///
|
||||
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
|
||||
/// it lives only for the process, so a cold start always fetches fresh.
|
||||
class EmergencyNoticeClient {
|
||||
EmergencyNoticeClient();
|
||||
|
||||
static const Duration cacheTtl = Duration(minutes: 1);
|
||||
|
||||
EmergencyNotice? _cached;
|
||||
String? _cachedUrl;
|
||||
DateTime? _cachedAt;
|
||||
|
||||
Future<EmergencyNotice?> fetch(String url) async {
|
||||
final cachedAt = _cachedAt;
|
||||
if (cachedAt != null &&
|
||||
_cachedUrl == url &&
|
||||
DateTime.now().difference(cachedAt) < cacheTtl) {
|
||||
return _cached;
|
||||
}
|
||||
|
||||
EmergencyNotice? result;
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
final response = await dio.get<String>(url);
|
||||
final raw = response.data;
|
||||
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
|
||||
} catch (_) {
|
||||
result = null;
|
||||
}
|
||||
|
||||
// Cache failures too, so a down server isn't retried on every resume.
|
||||
_cached = result;
|
||||
_cachedUrl = url;
|
||||
_cachedAt = DateTime.now();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import 'package:dio/dio.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../session/session.dart';
|
||||
import '../api_error.dart';
|
||||
import '../http_errors.dart';
|
||||
import '../marianumcloud/talk/talk_error.dart';
|
||||
import 'app_exception.dart';
|
||||
import 'auth_exception.dart';
|
||||
@@ -59,9 +61,10 @@ AppException? _dioToAppException(DioException error) {
|
||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||
final status = error.statusCode;
|
||||
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||
final preview = previewBody(error.body);
|
||||
final detail = preview.isEmpty
|
||||
? 'HTTP $status'
|
||||
: 'HTTP $status body=$preview';
|
||||
switch (status) {
|
||||
case 401:
|
||||
return AuthException.unauthorized(technicalDetails: detail);
|
||||
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||
if (error is AppException) return error.userMessage;
|
||||
|
||||
if (error is TalkError) return TalkException(error).userMessage;
|
||||
if (error is NextcloudUnavailableException) {
|
||||
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
|
||||
}
|
||||
|
||||
if (error is DioException) {
|
||||
final mapped = _dioToAppException(error);
|
||||
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
|
||||
bool errorAllowsRetry(Object? error) {
|
||||
if (error == null) return true;
|
||||
if (error is AppException) return error.allowRetry;
|
||||
if (error is NextcloudUnavailableException) return false;
|
||||
if (error is DioException) {
|
||||
final mapped = _dioToAppException(error);
|
||||
if (mapped != null) return mapped.allowRetry;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_exception.dart';
|
||||
|
||||
/// The user denied a system permission the action needs (camera, photos).
|
||||
/// Retrying won't help until the permission is granted in the system settings.
|
||||
class PermissionException extends AppException {
|
||||
const PermissionException({
|
||||
required super.userMessage,
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: false);
|
||||
|
||||
factory PermissionException.camera({String? technicalDetails}) =>
|
||||
PermissionException(
|
||||
userMessage:
|
||||
'Kein Zugriff auf die Kamera. Bitte erlaube der App den '
|
||||
'Kamerazugriff in den Einstellungen deines Geräts.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
|
||||
factory PermissionException.photos({String? technicalDetails}) =>
|
||||
PermissionException(
|
||||
userMessage:
|
||||
'Kein Zugriff auf deine Fotos. Bitte erlaube der App den '
|
||||
'Fotozugriff in den Einstellungen deines Geräts.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
|
||||
/// Maps the `*_access_denied` codes thrown by `image_picker`; `null` for any
|
||||
/// other platform error.
|
||||
static PermissionException? fromPlatform(PlatformException error) =>
|
||||
switch (error.code) {
|
||||
'camera_access_denied' => PermissionException.camera(
|
||||
technicalDetails: error.message,
|
||||
),
|
||||
'photo_access_denied' => PermissionException.photos(
|
||||
technicalDetails: error.message,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,22 @@ import 'app_exception.dart';
|
||||
class TalkException extends AppException {
|
||||
final TalkError source;
|
||||
|
||||
TalkException(this.source)
|
||||
TalkException(this.source, {String? technicalDetails})
|
||||
: super(
|
||||
userMessage: _mapMessage(source),
|
||||
technicalDetails:
|
||||
technicalDetails ??
|
||||
'Talk ${source.status} (${source.code}): ${source.message}',
|
||||
allowRetry: source.code >= 500,
|
||||
);
|
||||
|
||||
static String _mapMessage(TalkError e) {
|
||||
switch (e.code) {
|
||||
case 400:
|
||||
return 'Talk hat diese Anfrage abgelehnt. Bitte lade den Chat neu und '
|
||||
'versuche es erneut.';
|
||||
case 413:
|
||||
return 'Diese Nachricht ist zu lang.';
|
||||
case 401:
|
||||
return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.';
|
||||
case 403:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'errors/auth_exception.dart';
|
||||
import 'errors/network_exception.dart';
|
||||
import 'errors/not_found_exception.dart';
|
||||
import 'errors/server_exception.dart';
|
||||
|
||||
/// Runs [send] and converts transport-level failures (socket/timeout/client
|
||||
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
|
||||
/// Passes through whatever [send] produces, including `null` for the base-class
|
||||
/// request hooks that may skip the call.
|
||||
Future<http.Response?> sendGuarded(
|
||||
String label,
|
||||
Future<http.Response>? Function() send,
|
||||
) async {
|
||||
try {
|
||||
return await send();
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: '$label: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
|
||||
/// embedded in an [AppException]'s technical details without dumping headers.
|
||||
String previewBody(String body) {
|
||||
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}…' : collapsed;
|
||||
}
|
||||
|
||||
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
|
||||
String httpErrorDetail(String label, String body, int status) {
|
||||
final preview = previewBody(body);
|
||||
return preview.isEmpty
|
||||
? '$label -> HTTP $status'
|
||||
: '$label -> HTTP $status body=$preview';
|
||||
}
|
||||
|
||||
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
|
||||
/// [detail] as technical details: 401/403 map to auth errors, 404 to
|
||||
/// not-found, everything else to a generic server error.
|
||||
Never throwForStatus(int status, String detail) {
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
||||
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
|
||||
/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
|
||||
/// which is what the push-v2 registration binds to.
|
||||
///
|
||||
/// Must authenticate with the *real* password — an app password cannot mint
|
||||
@@ -18,21 +19,31 @@ class GetAppPassword {
|
||||
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Returns the freshly minted app password. Throws on any transport or
|
||||
/// protocol error — callers treat push registration as best-effort and swallow
|
||||
/// failures.
|
||||
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||
Future<String> run() async {
|
||||
final response = await _client.get(
|
||||
const label = 'Nextcloud getapppassword';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.get(
|
||||
NextcloudOcs.uri('core/getapppassword'),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
// Deliberately NOT the shared Authorization value: that one prefers
|
||||
// the app password, but an app password cannot mint another one —
|
||||
// this endpoint requires the real password.
|
||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
||||
'Authorization': SessionManager()
|
||||
.requireNextcloud()
|
||||
.realPasswordBasicAuthHeader,
|
||||
},
|
||||
);
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final data = (json as Map)['ocs']?['data'];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -38,9 +37,6 @@ class AutocompleteApi {
|
||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AutocompleteResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Mix of two Nextcloud surfaces:
|
||||
@@ -32,40 +27,27 @@ Uri _coreAvatarUri() {
|
||||
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
||||
}
|
||||
|
||||
Uri _userInfoUri() =>
|
||||
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
|
||||
Uri _userInfoUri() => NextcloudOcs.uri(
|
||||
'cloud/users/${SessionManager().requireNextcloud().username}',
|
||||
);
|
||||
|
||||
Future<http.Response> _send(
|
||||
Future<http.Response> Function(Uri uri, Map<String, String> headers)
|
||||
perform,
|
||||
Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
|
||||
Uri uri,
|
||||
) async {
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await perform(uri, headers);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
||||
}
|
||||
final response = (await sendGuarded(
|
||||
'Cloud $uri',
|
||||
() => perform(uri, headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status >= 200 && status < 300) return response;
|
||||
|
||||
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty
|
||||
? 'Cloud $uri -> HTTP $status'
|
||||
: 'Cloud $uri -> HTTP $status body=$preview';
|
||||
final detail = httpErrorDetail('Cloud $uri', response.body, status);
|
||||
log(detail);
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
throwForStatus(status, detail);
|
||||
}
|
||||
|
||||
class SetUserAvatar {
|
||||
@@ -116,16 +98,13 @@ class GetUserInfo {
|
||||
try {
|
||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(root['ocs'] as Map<String, dynamic>)['data']
|
||||
as Map<String, dynamic>;
|
||||
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||
return CloudUserInfo(
|
||||
userId: data['id'] as String,
|
||||
displayName: (data['displayname'] as String?) ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
throw ParseException(
|
||||
technicalDetails: 'Cloud $uri user info parse: $e',
|
||||
);
|
||||
throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import '../../model/account_data.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../model/endpoint_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
|
||||
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||
class NextcloudOcs {
|
||||
NextcloudOcs._();
|
||||
|
||||
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
|
||||
/// every response nests its `meta`/`data` under).
|
||||
static Map<String, dynamic> decode(String raw) =>
|
||||
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
||||
|
||||
static Map<String, String> headers() => {
|
||||
'Accept': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
'Authorization': AccountData().getBasicAuthHeader(),
|
||||
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
|
||||
};
|
||||
|
||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -28,9 +27,7 @@ class SearchFiles {
|
||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
||||
final data = ocs['data'] as Map<String, dynamic>;
|
||||
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||
return SearchFilesResponse.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
|
||||
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
||||
|
||||
@override
|
||||
GetChatResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
GetChatResponse assemble(String raw) =>
|
||||
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response> request(
|
||||
|
||||
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
|
||||
GetChatParams(
|
||||
lookIntoFuture: GetChatParamsSwitch.off,
|
||||
setReadMarker: GetChatParamsSwitch.on,
|
||||
limit: 200,
|
||||
// Small initial page; also the per-chat offline snapshot written to
|
||||
// localstore. Older messages are paged in on scroll-up via
|
||||
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
||||
limit: 50,
|
||||
),
|
||||
).run(),
|
||||
fromJson: GetChatResponse.fromJson,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../errors/server_exception.dart';
|
||||
import '../../../http_errors.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
|
||||
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
||||
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
|
||||
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
|
||||
/// when there are no older messages left — a normal "start of chat" outcome here.
|
||||
/// `setReadMarker=off` so paging into history never moves the read cursor.
|
||||
class GetChatHistory {
|
||||
final String chatToken;
|
||||
final int lastKnownMessageId;
|
||||
final int limit;
|
||||
|
||||
GetChatHistory({
|
||||
required this.chatToken,
|
||||
required this.lastKnownMessageId,
|
||||
required this.limit,
|
||||
});
|
||||
|
||||
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
||||
Future<GetChatResponse?> run() async {
|
||||
final params = GetChatParams(
|
||||
lookIntoFuture: GetChatParamsSwitch.off,
|
||||
lastKnownMessageId: lastKnownMessageId,
|
||||
includeLastKnown: GetChatParamsSwitch.off,
|
||||
setReadMarker: GetChatParamsSwitch.off,
|
||||
limit: limit,
|
||||
);
|
||||
final uri = NextcloudOcs.uri(
|
||||
'apps/spreed/api/v1/chat/$chatToken',
|
||||
queryParameters: params.toJson(),
|
||||
);
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final response = (await sendGuarded(
|
||||
'GetChatHistory $uri',
|
||||
() => http.get(uri, headers: headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status == 304) return null;
|
||||
if (status >= 200 && status < 300) {
|
||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||
..headers = response.headers;
|
||||
}
|
||||
throw ServerException(
|
||||
statusCode: status,
|
||||
technicalDetails: 'GetChatHistory $uri: HTTP $status',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ class GetChatResponseObject {
|
||||
Map<String, RichObjectString>? messageParameters;
|
||||
GetChatResponseObject? parent;
|
||||
|
||||
/// Whether the server flagged this message to be rendered as Markdown
|
||||
/// (capability `markdown-messages`). Absent on older servers/messages, in
|
||||
/// which case it stays `false` and the body renders as plain text.
|
||||
bool markdown;
|
||||
|
||||
GetChatResponseObject(
|
||||
this.id,
|
||||
this.token,
|
||||
@@ -57,8 +62,9 @@ class GetChatResponseObject {
|
||||
this.messageParameters,
|
||||
this.reactions,
|
||||
this.reactionsSelf,
|
||||
this.parent,
|
||||
);
|
||||
this.parent, [
|
||||
this.markdown = false,
|
||||
]);
|
||||
|
||||
factory GetChatResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetChatResponseObjectFromJson(json);
|
||||
|
||||
@@ -49,6 +49,7 @@ GetChatResponseObject _$GetChatResponseObjectFromJson(
|
||||
json['parent'] == null
|
||||
? null
|
||||
: GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>),
|
||||
json['markdown'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GetChatResponseObjectToJson(
|
||||
@@ -73,6 +74,7 @@ Map<String, dynamic> _$GetChatResponseObjectToJson(
|
||||
(k, e) => MapEntry(k, e.toJson()),
|
||||
),
|
||||
'parent': instance.parent?.toJson(),
|
||||
'markdown': instance.markdown,
|
||||
};
|
||||
|
||||
const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../errors/network_exception.dart';
|
||||
import '../../../errors/server_exception.dart';
|
||||
import '../../../http_errors.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
@@ -41,24 +37,17 @@ class LongPollChat {
|
||||
);
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http
|
||||
final response = (await sendGuarded(
|
||||
'LongPollChat $uri',
|
||||
() => http
|
||||
.get(uri, headers: headers)
|
||||
.timeout(Duration(seconds: timeoutSeconds + 15));
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
||||
}
|
||||
.timeout(Duration(seconds: timeoutSeconds + 15)),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status == 304) return null;
|
||||
if (status >= 200 && status < 300) {
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
|
||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||
..headers = response.headers;
|
||||
}
|
||||
throw ServerException(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../get_poll/get_poll_state_response.dart';
|
||||
import '../talk_api.dart';
|
||||
|
||||
@@ -12,12 +11,8 @@ class ClosePoll extends TalkApi<GetPollStateResponse> {
|
||||
: super('v1/poll/$token/$pollId', null);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetPollStateResponse assemble(String raw) =>
|
||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'create_room_params.dart';
|
||||
import 'create_room_response.dart';
|
||||
@@ -13,10 +12,8 @@ class CreateRoom extends TalkApi<CreateRoomResponse> {
|
||||
CreateRoom(this.params) : super('v4/room', params);
|
||||
|
||||
@override
|
||||
CreateRoomResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
CreateRoomResponse assemble(String raw) =>
|
||||
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response>? request(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_participants_response.dart';
|
||||
|
||||
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
|
||||
GetParticipants(this.token) : super('v4/room/$token/participants', null);
|
||||
|
||||
@override
|
||||
GetParticipantsResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetParticipantsResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetParticipantsResponse assemble(String raw) =>
|
||||
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_poll_state_response.dart';
|
||||
|
||||
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
|
||||
: super('v1/poll/$token/$pollId', null);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetPollStateResponse assemble(String raw) =>
|
||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_reactions_response.dart';
|
||||
|
||||
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
|
||||
: super('v1/reaction/$chatToken/$messageId', null);
|
||||
|
||||
@override
|
||||
GetReactionsResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetReactionsResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetReactionsResponse assemble(String raw) =>
|
||||
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response>? request(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_shared_items_response.dart';
|
||||
|
||||
/// Fetches the messages that shared an item of a given [objectType] in a chat
|
||||
/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using
|
||||
/// the `X-Chat-Last-Given` response header (see
|
||||
/// [GetSharedItemsResponse.lastGivenMessageId]).
|
||||
///
|
||||
/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`,
|
||||
/// `deckcard`, `recording`, `other`.
|
||||
class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
|
||||
GetSharedItems(
|
||||
String token, {
|
||||
required String objectType,
|
||||
int limit = 20,
|
||||
int? lastKnownMessageId,
|
||||
}) : super(
|
||||
'v1/chat/$token/share',
|
||||
null,
|
||||
getParameters: {
|
||||
'objectType': objectType,
|
||||
'limit': limit,
|
||||
'lastKnownMessageId': ?lastKnownMessageId,
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
GetSharedItemsResponse assemble(String raw) =>
|
||||
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.get(uri, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_shared_items_overview_response.dart';
|
||||
|
||||
/// Fetches the latest shared items of every type at once (Talk's
|
||||
/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to
|
||||
/// show and to seed their first page. [limit] caps the items returned per type.
|
||||
class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
|
||||
GetSharedItemsOverview(String token, {int limit = 20})
|
||||
: super(
|
||||
'v1/chat/$token/share/overview',
|
||||
null,
|
||||
getParameters: {'limit': limit},
|
||||
);
|
||||
|
||||
@override
|
||||
GetSharedItemsOverviewResponse assemble(String raw) =>
|
||||
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
Uri uri,
|
||||
Object? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.get(uri, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import '../../../api_response.dart';
|
||||
import '../chat/get_chat_response.dart';
|
||||
|
||||
/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared
|
||||
/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`,
|
||||
/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the
|
||||
/// message structure.
|
||||
class GetSharedItemsOverviewResponse extends ApiResponse {
|
||||
final Map<String, List<GetChatResponseObject>> itemsByType;
|
||||
|
||||
GetSharedItemsOverviewResponse(this.itemsByType);
|
||||
|
||||
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||
final data = ocs['data'];
|
||||
final result = <String, List<GetChatResponseObject>>{};
|
||||
if (data is Map<String, dynamic>) {
|
||||
for (final entry in data.entries) {
|
||||
final value = entry.value;
|
||||
final raw = switch (value) {
|
||||
List<dynamic> list => list,
|
||||
Map<dynamic, dynamic> map => map.values,
|
||||
_ => const <dynamic>[],
|
||||
};
|
||||
result[entry.key] = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GetChatResponseObject.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return GetSharedItemsOverviewResponse(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../../api_response.dart';
|
||||
import '../chat/get_chat_response.dart';
|
||||
|
||||
/// Response of Talk's shared-items endpoints. The message objects carry the
|
||||
/// same structure as regular chat messages, so we reuse [GetChatResponseObject]
|
||||
/// (the shared file lives in `messageParameters['file']`).
|
||||
///
|
||||
/// The server returns `data` either as a list or as a message-id-keyed map
|
||||
/// depending on version; [fromOcs] normalises both to a list.
|
||||
class GetSharedItemsResponse extends ApiResponse {
|
||||
final List<GetChatResponseObject> items;
|
||||
|
||||
GetSharedItemsResponse(this.items);
|
||||
|
||||
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||
final data = ocs['data'];
|
||||
final raw = switch (data) {
|
||||
List<dynamic> list => list,
|
||||
Map<dynamic, dynamic> map => map.values,
|
||||
_ => const <dynamic>[],
|
||||
};
|
||||
final items = raw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GetChatResponseObject.fromJson)
|
||||
.toList();
|
||||
return GetSharedItemsResponse(items);
|
||||
}
|
||||
|
||||
/// Offset for the next page, taken from the `X-Chat-Last-Given` header.
|
||||
/// Null when the header is absent (older server) — treat that as "stop".
|
||||
int? get lastGivenMessageId {
|
||||
final value = headers?['x-chat-last-given'];
|
||||
return value == null ? null : int.tryParse(value);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_room_params.dart';
|
||||
import 'get_room_response.dart';
|
||||
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
||||
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
||||
|
||||
@override
|
||||
GetRoomResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
GetRoomResponse assemble(String raw) =>
|
||||
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../files_sharing/file_sharing_api.dart';
|
||||
import '../files_sharing/file_sharing_api_params.dart';
|
||||
import '../webdav/webdav_api.dart';
|
||||
|
||||
/// WebDAV folder under which Talk-shared files are uploaded before being
|
||||
/// linked into a chat.
|
||||
const String talkShareFolder = 'MarianumMobile';
|
||||
|
||||
Future<void>? _shareFolderReady;
|
||||
|
||||
/// Creates [talkShareFolder] if it is missing, at most once per session — the
|
||||
/// folder is permanent, so every later upload would just pay a round trip to
|
||||
/// be told it already exists (WebDAV answers MKCOL on an existing collection
|
||||
/// with 405, which is the normal case here and must not surface as an error).
|
||||
Future<void> ensureTalkShareFolder() =>
|
||||
_shareFolderReady ??= _createTalkShareFolder();
|
||||
|
||||
Future<void> _createTalkShareFolder() async {
|
||||
try {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
|
||||
} on DynamiteApiException catch (e) {
|
||||
// Anything but "already exists" leaves the folder unconfirmed, so the next
|
||||
// upload has to try again.
|
||||
if (e.statusCode != 405) _shareFolderReady = null;
|
||||
} catch (_) {
|
||||
_shareFolderReady = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts each already-uploaded WebDAV path as a Talk share (ShareType 10) to
|
||||
/// the given conversation token. Calls run concurrently — the server accepts
|
||||
/// parallel posts and the picker UI is blocked anyway, so we shouldn't pay
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../api_params.dart';
|
||||
import '../../api_request.dart';
|
||||
import '../../api_response.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../../errors/talk_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'talk_error.dart';
|
||||
|
||||
enum TalkApiMethod { get, post, put, delete }
|
||||
|
||||
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
abstract class TalkApi<T extends ApiResponse?> {
|
||||
String path;
|
||||
ApiParams? body;
|
||||
Map<String, String>? headers;
|
||||
Map<String, dynamic>? getParameters;
|
||||
|
||||
http.Response? response;
|
||||
|
||||
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
||||
|
||||
Future<http.Response>? request(
|
||||
@@ -40,43 +33,35 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
);
|
||||
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
||||
|
||||
final http.Response data;
|
||||
try {
|
||||
final raw = await request(endpoint, body, mergedHeaders);
|
||||
if (raw == null) {
|
||||
final data = await sendGuarded(
|
||||
'Talk $endpoint',
|
||||
() => request(endpoint, body, mergedHeaders),
|
||||
);
|
||||
if (data == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||
technicalDetails: 'Talk request returned null',
|
||||
);
|
||||
}
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
}
|
||||
|
||||
final status = data.statusCode;
|
||||
if (status < 200 || status >= 300) {
|
||||
// Talk's OCS errors carry the real reason in the body (expired session,
|
||||
// removed participant, ...); include a trimmed preview so the dialog and
|
||||
// logs surface the cause instead of just the bare status code.
|
||||
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty
|
||||
? 'Talk $endpoint -> HTTP $status'
|
||||
: 'Talk $endpoint -> HTTP $status body=$preview';
|
||||
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
|
||||
log(detail);
|
||||
if (status == 401) {
|
||||
throw AuthException.unauthorized(technicalDetails: detail);
|
||||
// Talk's own codes need their own wording: a 400/412/413 here is a
|
||||
// rejected request, not a struggling server, and "try again later"
|
||||
// would send the user in circles. 401/403/404 keep the shared mapping
|
||||
// so the auth handling stays in one place.
|
||||
if (status == 400 || status == 412 || status == 413 || status == 429) {
|
||||
throw TalkException(
|
||||
TalkError('failure', status, ''),
|
||||
technicalDetails: detail,
|
||||
);
|
||||
}
|
||||
if (status == 403) {
|
||||
throw AuthException.forbidden(technicalDetails: detail);
|
||||
}
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
throwForStatus(status, detail);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../get_poll/get_poll_state_response.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'vote_poll_params.dart';
|
||||
@@ -22,12 +23,8 @@ class VotePoll extends TalkApi<GetPollStateResponse> {
|
||||
);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetPollStateResponse assemble(String raw) =>
|
||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response>? request(
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../api_request.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../api_response.dart';
|
||||
|
||||
abstract class WebdavApi<T> extends ApiRequest {
|
||||
abstract class WebdavApi<T> {
|
||||
T genericParams;
|
||||
|
||||
WebdavApi(this.genericParams) {
|
||||
establishWebdavConnection();
|
||||
}
|
||||
WebdavApi(this.genericParams);
|
||||
|
||||
Future<ApiResponse> run();
|
||||
|
||||
static Future<WebDavClient> webdav = establishWebdavConnection();
|
||||
static Future<WebDavClient>? _webdav;
|
||||
static String? _webdavSecret;
|
||||
|
||||
/// Shared WebDAV client. Rebuilt whenever the effective Nextcloud secret
|
||||
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||
/// authenticating with stale credentials.
|
||||
static Future<WebDavClient> get webdav {
|
||||
final secret = SessionManager().requireNextcloud().secret;
|
||||
if (_webdav == null || _webdavSecret != secret) {
|
||||
_webdavSecret = secret;
|
||||
_webdav = establishWebdavConnection();
|
||||
}
|
||||
return _webdav!;
|
||||
}
|
||||
|
||||
static Future<WebDavClient> establishWebdavConnection() async =>
|
||||
NextcloudClient(
|
||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||
password: AccountData().getPassword(),
|
||||
loginName: AccountData().getUsername(),
|
||||
// App password preferred — with 2FA the real password is not accepted
|
||||
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
|
||||
password: SessionManager().requireNextcloud().secret,
|
||||
loginName: SessionManager().requireNextcloud().username,
|
||||
).webdav;
|
||||
|
||||
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
||||
/// authenticate via the [AccountData.authHeaders] header instead.
|
||||
/// authenticate via the [NextcloudCredentials.authHeaders] header instead.
|
||||
static String buildWebdavUrl() =>
|
||||
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/';
|
||||
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../session/session.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../queries/auth_login/auth_login.dart';
|
||||
import 'device_token_name.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
||||
/// re-logs in once with the credentials in [AccountData] before retrying.
|
||||
/// renews the token once before retrying. Only password accounts can renew
|
||||
/// silently; passwordless accounts surface the 401.
|
||||
class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
static const _retriedKey = 'mc_auth_retried';
|
||||
|
||||
@@ -64,6 +66,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
}
|
||||
final refreshed = await _attemptReLogin();
|
||||
if (!refreshed) {
|
||||
if (SessionManager().current is GuardianSession) {
|
||||
SessionManager().reportUnauthorized();
|
||||
}
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
@@ -87,11 +92,12 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
}
|
||||
|
||||
Future<bool> _performReLogin() async {
|
||||
if (!AccountData().isPopulated()) return false;
|
||||
final session = SessionManager().current;
|
||||
if (session is! CredentialSession) return false;
|
||||
try {
|
||||
await _loginClient.run(
|
||||
username: AccountData().getUsername(),
|
||||
password: AccountData().getPassword(),
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
return true;
|
||||
|
||||
@@ -1,35 +1,46 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../session/session.dart';
|
||||
import '../../../session/session_lifecycle.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../queries/auth_logout/auth_logout.dart';
|
||||
import '../queries/auth_me/auth_me.dart';
|
||||
import '../queries/auth_verify/auth_verify.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// Background credential probe — a server-side password rotation forces a
|
||||
/// re-login on the next cold start even when the bearer token would still
|
||||
/// be accepted.
|
||||
/// Credential probe. For password accounts a server-side password rotation
|
||||
/// forces a re-login on the next cold start even when the bearer token would
|
||||
/// still be accepted; for guardians it confirms a rejected token before the
|
||||
/// session is dropped.
|
||||
class SessionValidator {
|
||||
static Future<void> probeStored({
|
||||
required Future<void> Function() onInvalidated,
|
||||
}) async {
|
||||
if (!AccountData().isPopulated()) return;
|
||||
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
|
||||
// session must be skipped here or its missing token would 401 into a logout.
|
||||
if (AccountData().isDemo) return;
|
||||
final username = AccountData().getUsername();
|
||||
final password = AccountData().getPassword();
|
||||
final session = SessionManager().current;
|
||||
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||
// session must be skipped or its missing token would 401 into a logout.
|
||||
if (session == null || session.isDemo) return;
|
||||
try {
|
||||
switch (session) {
|
||||
case CredentialSession(:final username, :final password):
|
||||
await AuthVerify().run(username: username, password: password);
|
||||
case GuardianSession():
|
||||
await AuthMe().run();
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (e.statusCode != 401) return;
|
||||
log('MC: stored credentials rejected — forcing re-login');
|
||||
await AuthLogout().run();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
await AccountData().removeData();
|
||||
log('MC: stored session rejected — forcing re-login');
|
||||
await SessionLifecycle.signOut(
|
||||
notice: switch (session) {
|
||||
CredentialSession() =>
|
||||
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
||||
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
|
||||
GuardianSession() =>
|
||||
'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.',
|
||||
},
|
||||
);
|
||||
await onInvalidated();
|
||||
} catch (e) {
|
||||
log('MC: background credential check failed (transient): $e');
|
||||
log('MC: background session check failed (transient): $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../errors/auth_exception.dart';
|
||||
|
||||
/// `first_unlock` accessibility so the token can be read during background
|
||||
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
||||
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
|
||||
);
|
||||
|
||||
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
||||
/// separate from `SessionManager` because the username/password live on (Nextcloud
|
||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||
class MarianumConnectTokenStorage {
|
||||
static const _tokenKey = 'mc_bearer_token';
|
||||
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
|
||||
|
||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||
|
||||
/// Request options carrying the stored token, for probes that bypass the
|
||||
/// auth interceptor. Throws [AuthException] when no token is stored.
|
||||
Future<Options> requireBearerOptions(String caller) async {
|
||||
final token = await readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw AuthException.unauthorized(
|
||||
technicalDetails: '$caller: no bearer token in storage',
|
||||
);
|
||||
}
|
||||
return Options(headers: {'Authorization': 'Bearer $token'});
|
||||
}
|
||||
|
||||
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
||||
|
||||
Future<DateTime?> readExpiresAt() async {
|
||||
|
||||
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
|
||||
/// The error code of a rejected call: the server answers either with JSON
|
||||
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
|
||||
/// generic error handler.
|
||||
String? marianumConnectErrorCode(Object? body) {
|
||||
if (body is Map) {
|
||||
final code = body['error'];
|
||||
return code is String ? code : null;
|
||||
}
|
||||
if (body is String) {
|
||||
const prefix = 'Fehler: ';
|
||||
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
|
||||
.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Converts a DioException raised against the Marianum-Connect API into one of
|
||||
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
|
||||
/// that just want to render an error message.
|
||||
|
||||
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
|
||||
class MarianumConnectApi {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 20);
|
||||
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
|
||||
|
||||
static final Dio _instance = _build();
|
||||
|
||||
static Dio dio() => _instance;
|
||||
|
||||
/// A fresh dio with the standard JSON options but no interceptors — used by
|
||||
/// the auth queries (login/verify) that must bypass the bearer/demo
|
||||
/// interceptors to avoid a re-auth loop.
|
||||
static Dio plainDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
receiveTimeout: _plainReceiveTimeout,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json',
|
||||
),
|
||||
);
|
||||
|
||||
static Dio _build() {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../errors/app_exception.dart';
|
||||
import 'errors/marianumconnect_error.dart';
|
||||
import 'marianumconnect_api.dart';
|
||||
import 'marianumconnect_endpoint.dart';
|
||||
@@ -23,7 +26,53 @@ abstract class MarianumConnectQuery {
|
||||
try {
|
||||
return await body();
|
||||
} on DioException catch (e) {
|
||||
throw mapMarianumConnectError(e);
|
||||
throw mapError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The AppException a failed call surfaces as. Query families with domain
|
||||
/// error codes override this instead of re-implementing [guard].
|
||||
AppException mapError(DioException error) => mapMarianumConnectError(error);
|
||||
|
||||
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||
Future<T> getObject<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic> json) fromJson, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint(path),
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
return fromJson(response.data!);
|
||||
});
|
||||
|
||||
/// GETs [path] and maps each element of the JSON array body with [fromJson].
|
||||
Future<List<T>> getList<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic> json) fromJson, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint(path),
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
return response.data!
|
||||
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// GETs the raw bytes of [path] (files that need the bearer token).
|
||||
Future<Uint8List> getBytes(String path) => guard(() async {
|
||||
final response = await dio.get<List<int>>(
|
||||
endpoint(path),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final bytes = response.data!;
|
||||
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
|
||||
});
|
||||
|
||||
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||
String isoDate(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// GETs the selectable classes for the absence form (`absence/classes`). The
|
||||
/// body is a bare JSON string array, so [getList] (which maps objects) does not
|
||||
/// fit — read the raw list and cast.
|
||||
class AbsenceClasses extends MarianumConnectQuery {
|
||||
AbsenceClasses({super.dio});
|
||||
|
||||
Future<List<String>> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(endpoint('absence/classes'));
|
||||
return response.data!.cast<String>();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import '../../../errors/app_exception.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'absence_prefill_response.dart';
|
||||
|
||||
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
|
||||
/// Guardians pass the [childId] the report is for; the identity then comes
|
||||
/// from that child.
|
||||
class AbsencePrefill extends MarianumConnectQuery {
|
||||
AbsencePrefill({super.dio});
|
||||
|
||||
Future<AbsencePrefillResponse> run({String? childId}) => getObject(
|
||||
'absence/prefill',
|
||||
AbsencePrefillResponse.fromJson,
|
||||
queryParameters: {'childId': ?childId},
|
||||
);
|
||||
}
|
||||
|
||||
/// The prefill of a guardian's report is missing a field the form cannot ask
|
||||
/// for: name and class come from the child and are not editable there, so an
|
||||
/// incomplete prefill would leave a locked, unsubmittable form behind.
|
||||
class AbsencePrefillIncompleteException extends AppException {
|
||||
const AbsencePrefillIncompleteException({super.technicalDetails})
|
||||
: super(
|
||||
userMessage:
|
||||
'Für dieses Kind sind in der Schulverwaltung noch nicht alle '
|
||||
'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. '
|
||||
'Bitte wende dich an das Sekretariat.',
|
||||
allowRetry: false,
|
||||
);
|
||||
}
|
||||
@@ -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,35 @@
|
||||
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. For guardians the
|
||||
/// server takes name and class from the child given by [childId].
|
||||
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,
|
||||
String? childId,
|
||||
}) => 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,
|
||||
'childId': ?childId,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../auth_link/pending_guardian_request.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'guardian_login_exception.dart';
|
||||
|
||||
class AuthGuardianRequestResponse {
|
||||
final String requestId;
|
||||
final DateTime expiresAt;
|
||||
final DateTime resendAvailableAt;
|
||||
final int codeLength;
|
||||
|
||||
const AuthGuardianRequestResponse({
|
||||
required this.requestId,
|
||||
required this.expiresAt,
|
||||
required this.resendAvailableAt,
|
||||
required this.codeLength,
|
||||
});
|
||||
|
||||
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
|
||||
AuthGuardianRequestResponse(
|
||||
requestId: json['requestId'] as String,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
|
||||
codeLength:
|
||||
json['codeLength'] as int? ??
|
||||
PendingGuardianRequest.defaultCodeLength,
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts a passwordless guardian login: the server mails a code and a link.
|
||||
/// An unknown address is reported as such (`email_not_registered`) instead of
|
||||
/// being answered like a known one — a deliberate trade: without it guardians
|
||||
/// wait for a mail that never arrives. Registered addresses are therefore
|
||||
/// enumerable; keep that in mind when changing the server contract.
|
||||
class AuthGuardianRequest extends MarianumConnectQuery {
|
||||
AuthGuardianRequest({Dio? dio})
|
||||
: super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
Future<AuthGuardianRequestResponse> run({
|
||||
required String email,
|
||||
required String deviceChallenge,
|
||||
required String tokenName,
|
||||
}) async {
|
||||
try {
|
||||
final response = await dio.post<Map<String, dynamic>>(
|
||||
endpoint('auth/guardian/request'),
|
||||
data: {
|
||||
'email': email,
|
||||
'deviceChallenge': deviceChallenge,
|
||||
'tokenName': tokenName,
|
||||
},
|
||||
);
|
||||
return AuthGuardianRequestResponse.fromJson(response.data!);
|
||||
} on DioException catch (e) {
|
||||
throw GuardianLoginException.fromDio(e, verifying: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import '../auth_login/auth_login_response.dart';
|
||||
import 'guardian_login_exception.dart';
|
||||
|
||||
/// Completes a guardian login with the mailed code or link token and stores
|
||||
/// the issued bearer token.
|
||||
class AuthGuardianVerify extends MarianumConnectQuery {
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
|
||||
AuthGuardianVerify({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
Future<AuthLoginResponse> run({
|
||||
required String requestId,
|
||||
required String deviceVerifier,
|
||||
required String tokenName,
|
||||
String? code,
|
||||
String? linkToken,
|
||||
}) async {
|
||||
assert((code == null) != (linkToken == null));
|
||||
try {
|
||||
final response = await dio.post<Map<String, dynamic>>(
|
||||
endpoint('auth/guardian/verify'),
|
||||
data: {
|
||||
'requestId': requestId,
|
||||
'deviceVerifier': deviceVerifier,
|
||||
'tokenName': tokenName,
|
||||
'code': ?code,
|
||||
'linkToken': ?linkToken,
|
||||
},
|
||||
);
|
||||
final payload = AuthLoginResponse.fromJson(response.data!);
|
||||
await _tokenStorage.write(
|
||||
token: payload.token,
|
||||
tokenId: payload.tokenId,
|
||||
expiresAt: payload.expiresAt,
|
||||
);
|
||||
return payload;
|
||||
} on DioException catch (e) {
|
||||
throw GuardianLoginException.fromDio(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../errors/app_exception.dart';
|
||||
import '../../errors/marianumconnect_error.dart';
|
||||
|
||||
enum GuardianLoginError {
|
||||
invalidRequest,
|
||||
emailNotRegistered,
|
||||
accountDisabled,
|
||||
invalidCode,
|
||||
deviceMismatch,
|
||||
requestConsumed,
|
||||
requestExpired,
|
||||
tooManyAttempts,
|
||||
rateLimited,
|
||||
unsupportedServer,
|
||||
}
|
||||
|
||||
/// A rejected guardian login step with the reason the server reported.
|
||||
class GuardianLoginException extends AppException {
|
||||
final GuardianLoginError error;
|
||||
final int? attemptsLeft;
|
||||
|
||||
const GuardianLoginException(
|
||||
this.error, {
|
||||
required super.userMessage,
|
||||
this.attemptsLeft,
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: false);
|
||||
|
||||
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
|
||||
/// login reason become [GuardianLoginException]; everything else keeps the
|
||||
/// generic MarianumConnect mapping (network, 5xx, …).
|
||||
///
|
||||
/// [verifying] false marks the e-mail step, where no code exists yet: a bare
|
||||
/// 401 (reverse proxy, gateway) must not be reported as a wrong code there.
|
||||
static AppException fromDio(DioException e, {bool verifying = true}) {
|
||||
final response = e.response;
|
||||
final status = response?.statusCode;
|
||||
if (status == null || status < 400 || status >= 500) {
|
||||
return mapMarianumConnectError(e);
|
||||
}
|
||||
final (code, attemptsLeft) = _parseBody(response!.data);
|
||||
final error = _errorFor(code, status, verifying: verifying);
|
||||
if (error == null) return mapMarianumConnectError(e);
|
||||
return GuardianLoginException(
|
||||
error,
|
||||
attemptsLeft: attemptsLeft,
|
||||
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
|
||||
technicalDetails: 'MC $status: ${response.data}',
|
||||
);
|
||||
}
|
||||
|
||||
static String messageFor(
|
||||
GuardianLoginError error, {
|
||||
int? attemptsLeft,
|
||||
}) => switch (error) {
|
||||
GuardianLoginError.invalidRequest =>
|
||||
'Bitte gib eine gültige E-Mail-Adresse ein.',
|
||||
GuardianLoginError.emailNotRegistered =>
|
||||
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
|
||||
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
|
||||
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
|
||||
GuardianLoginError.accountDisabled =>
|
||||
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
|
||||
'das Sekretariat.',
|
||||
GuardianLoginError.invalidCode =>
|
||||
attemptsLeft == null
|
||||
? 'Der Code ist falsch.'
|
||||
: 'Der Code ist falsch. Noch $attemptsLeft '
|
||||
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
|
||||
GuardianLoginError.deviceMismatch =>
|
||||
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
|
||||
'Bitte gib stattdessen den Code aus der E-Mail ein.',
|
||||
GuardianLoginError.requestConsumed =>
|
||||
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
|
||||
'Code an.',
|
||||
GuardianLoginError.requestExpired =>
|
||||
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
|
||||
GuardianLoginError.tooManyAttempts =>
|
||||
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
|
||||
GuardianLoginError.rateLimited =>
|
||||
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
|
||||
'erneut.',
|
||||
GuardianLoginError.unsupportedServer =>
|
||||
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
|
||||
'Bitte versuche es später erneut.',
|
||||
};
|
||||
|
||||
static (String?, int?) _parseBody(Object? data) {
|
||||
final attempts = data is Map ? data['attemptsLeft'] : null;
|
||||
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
|
||||
}
|
||||
|
||||
static GuardianLoginError? _errorFor(
|
||||
String? code,
|
||||
int status, {
|
||||
required bool verifying,
|
||||
}) => switch (code) {
|
||||
'invalid_request' => GuardianLoginError.invalidRequest,
|
||||
'email_not_registered' => GuardianLoginError.emailNotRegistered,
|
||||
'account_disabled' => GuardianLoginError.accountDisabled,
|
||||
'invalid_code' => GuardianLoginError.invalidCode,
|
||||
'device_mismatch' => GuardianLoginError.deviceMismatch,
|
||||
'request_consumed' => GuardianLoginError.requestConsumed,
|
||||
'request_expired' => GuardianLoginError.requestExpired,
|
||||
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
|
||||
_ => switch (status) {
|
||||
401 when verifying => GuardianLoginError.invalidCode,
|
||||
// The server names an unknown address explicitly
|
||||
// (`email_not_registered`); a bare 404/405 means the endpoint itself
|
||||
// is missing, i.e. a server version without guardian login.
|
||||
404 || 405 => GuardianLoginError.unsupportedServer,
|
||||
409 => GuardianLoginError.requestConsumed,
|
||||
410 => GuardianLoginError.requestExpired,
|
||||
429 => GuardianLoginError.rateLimited,
|
||||
_ => null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'auth_login_response.dart';
|
||||
|
||||
@@ -9,9 +10,6 @@ import 'auth_login_response.dart';
|
||||
/// run through the shared dio instance — that one has the interceptor, which
|
||||
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
||||
class AuthLogin extends MarianumConnectQuery {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
|
||||
AuthLogin({
|
||||
@@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery {
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
super(dio: dio ?? _buildDio());
|
||||
|
||||
static Dio _buildDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
receiveTimeout: _receiveTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json',
|
||||
),
|
||||
);
|
||||
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
Future<AuthLoginResponse> run({
|
||||
required String username,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../errors/auth_exception.dart';
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Probes that the stored bearer token is still accepted. Used for accounts
|
||||
/// without a password (guardians), whose token cannot be renewed silently.
|
||||
///
|
||||
/// Bypasses the shared dio singleton so the auth interceptor does not react
|
||||
/// to the 401 this probe is meant to observe.
|
||||
class AuthMe extends MarianumConnectQuery {
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
|
||||
AuthMe({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
/// Throws [AuthException] when the token is missing or rejected.
|
||||
Future<void> run() async {
|
||||
final options = await _tokenStorage.requireBearerOptions('AuthMe');
|
||||
return guard(() async {
|
||||
await dio.get<void>(endpoint('auth/me'), options: options);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../errors/auth_exception.dart';
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Probes that the stored bearer token still maps to the given credentials.
|
||||
@@ -12,9 +13,6 @@ import '../../marianumconnect_query.dart';
|
||||
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||
/// and obscure a real 401 with a silent re-login.
|
||||
class AuthVerify extends MarianumConnectQuery {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
|
||||
AuthVerify({
|
||||
@@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery {
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
super(dio: dio ?? _buildDio());
|
||||
|
||||
static Dio _buildDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
receiveTimeout: _receiveTimeout,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json',
|
||||
),
|
||||
);
|
||||
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
||||
/// user, token missing, or token rejected), other [AppException]s on
|
||||
@@ -41,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final token = await _tokenStorage.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw AuthException.unauthorized(
|
||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
||||
);
|
||||
}
|
||||
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
|
||||
return guard(() async {
|
||||
await dio.post<void>(
|
||||
endpoint('auth/verify'),
|
||||
data: {'username': username, 'password': password},
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
options: options,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import 'get_breakers_response.dart';
|
||||
class GetBreakers extends MarianumConnectQuery {
|
||||
GetBreakers({super.dio});
|
||||
|
||||
Future<GetBreakersResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
|
||||
return GetBreakersResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<GetBreakersResponse> run() =>
|
||||
getObject('breaker', GetBreakersResponse.fromJson);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ import 'get_capabilities_response.dart';
|
||||
class GetCapabilities extends MarianumConnectQuery {
|
||||
GetCapabilities({super.dio});
|
||||
|
||||
Future<CapabilitiesResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('me/capabilities'),
|
||||
);
|
||||
return CapabilitiesResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<CapabilitiesResponse> run() =>
|
||||
getObject('me/capabilities', CapabilitiesResponse.fromJson);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'guardian_child.dart';
|
||||
|
||||
part 'get_capabilities_response.g.dart';
|
||||
|
||||
/// Slimmed-down capability flags the mobile UI gates features on. The backend
|
||||
@@ -16,9 +18,28 @@ class CapabilitiesResponse {
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool pushNotifications;
|
||||
|
||||
/// How many days into the past/future the user may view the timetable.
|
||||
/// `null` (absent) means unlimited — the school year alone governs. The
|
||||
/// backend widens both to at least cover the current Mon–Sun week.
|
||||
final int? timetablePastDays;
|
||||
|
||||
final int? timetableFutureDays;
|
||||
|
||||
/// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
|
||||
/// backend predates the field or has no record for the user.
|
||||
final String? userType;
|
||||
|
||||
/// Students linked to a guardian account; empty for everyone else.
|
||||
@JsonKey(defaultValue: <GuardianChild>[])
|
||||
final List<GuardianChild> children;
|
||||
|
||||
CapabilitiesResponse({
|
||||
required this.viewForeignTimetables,
|
||||
required this.pushNotifications,
|
||||
this.timetablePastDays,
|
||||
this.timetableFutureDays,
|
||||
this.userType,
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -11,6 +11,14 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||
) => CapabilitiesResponse(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||
userType: json['userType'] as String?,
|
||||
children:
|
||||
(json['children'] as List<dynamic>?)
|
||||
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
@@ -18,4 +26,8 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
) => <String, dynamic>{
|
||||
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||
'pushNotifications': instance.pushNotifications,
|
||||
'timetablePastDays': instance.timetablePastDays,
|
||||
'timetableFutureDays': instance.timetableFutureDays,
|
||||
'userType': instance.userType,
|
||||
'children': instance.children,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'guardian_child.freezed.dart';
|
||||
part 'guardian_child.g.dart';
|
||||
|
||||
/// A student linked to the signed-in guardian. [id] is an opaque server id,
|
||||
/// not a WebUntis id.
|
||||
@freezed
|
||||
abstract class GuardianChild with _$GuardianChild {
|
||||
const GuardianChild._();
|
||||
|
||||
const factory GuardianChild({
|
||||
required String id,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
@Default('') String className,
|
||||
}) = _GuardianChild;
|
||||
|
||||
factory GuardianChild.fromJson(Map<String, Object?> json) =>
|
||||
_$GuardianChildFromJson(json);
|
||||
|
||||
String get displayName => '$firstName $lastName'.trim();
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'guardian_child.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$GuardianChild {
|
||||
|
||||
String get id; String get firstName; String get lastName; String get className;
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
|
||||
|
||||
/// Serializes this GuardianChild to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
final _this = this as GuardianChild;
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
final _this = this as GuardianChild;
|
||||
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final _this = this as GuardianChild;
|
||||
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $GuardianChildCopyWith<$Res> {
|
||||
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String firstName, String lastName, String className
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$GuardianChildCopyWithImpl<$Res>
|
||||
implements $GuardianChildCopyWith<$Res> {
|
||||
_$GuardianChildCopyWithImpl(this._self, this._then);
|
||||
|
||||
final GuardianChild _self;
|
||||
final $Res Function(GuardianChild) _then;
|
||||
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
|
||||
return _then(GuardianChild(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [GuardianChild].
|
||||
extension GuardianChildPatterns on GuardianChild {
|
||||
/// 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( _GuardianChild value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild() 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( _GuardianChild value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild():
|
||||
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( _GuardianChild value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild() 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( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className);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( String id, String firstName, String lastName, String className) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild():
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className);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( String id, String firstName, String lastName, String className)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _GuardianChild() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _GuardianChild extends GuardianChild {
|
||||
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._();
|
||||
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String firstName;
|
||||
@override final String lastName;
|
||||
@override@JsonKey() final String className;
|
||||
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$GuardianChildToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(runtimeType,id,firstName,lastName,className);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
|
||||
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String firstName, String lastName, String className
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$GuardianChildCopyWithImpl<$Res>
|
||||
implements _$GuardianChildCopyWith<$Res> {
|
||||
__$GuardianChildCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _GuardianChild _self;
|
||||
final $Res Function(_GuardianChild) _then;
|
||||
|
||||
/// Create a copy of GuardianChild
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
|
||||
return _then(_GuardianChild(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user