Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d6b3410e | |||
| 067012cc84 |
-12
@@ -350,15 +350,3 @@ hs_err_pid*
|
||||
|
||||
*.idea*
|
||||
**/.DS_store
|
||||
|
||||
# Fastlane (Play-Store-Upload)
|
||||
android/fastlane/report.xml
|
||||
android/fastlane/Preview.html
|
||||
android/fastlane/play-service-account.json
|
||||
android/fastlane/README.md.bak
|
||||
# lokale Screenshots außerhalb der Fastlane-Metadaten
|
||||
/screenshots/
|
||||
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
|
||||
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
||||
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
||||
android/fastlane/metadata/android/**/images/**/*.png
|
||||
|
||||
@@ -4,10 +4,4 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Allow cleartext HTTP in debug builds so developers can point the
|
||||
Marianum-Connect custom endpoint at a local backend (e.g.
|
||||
http://10.0.2.2:8080 from the Android emulator). Release builds
|
||||
keep the default cleartext block. -->
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Marianum Fulda"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -48,35 +48,6 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Fallback small icon + accent for anything FCM ever renders itself
|
||||
(e.g. plain notification messages). Launcher mipmaps are unusable
|
||||
as small icons: Android only draws their alpha silhouette, which
|
||||
shows up as a solid circle in the status bar. -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_notification" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/notification_accent" />
|
||||
|
||||
<!-- flutter_local_notifications liefert Notification-Action-Taps (Talk:
|
||||
Antworten / Gelesen) als expliziten Broadcast an diesen Receiver.
|
||||
Das Plugin deklariert ihn NICHT selbst — ohne diesen Eintrag kommt
|
||||
der Tap nirgends an und der Background-Handler startet nie. -->
|
||||
<receiver
|
||||
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- background_downloader runs large/long downloads in a foreground
|
||||
service (WorkManager) so they survive the app being backgrounded and
|
||||
beat the default 9-minute WorkManager timeout. On API 34+ the
|
||||
dataSync foreground service type must be declared explicitly here;
|
||||
see the FOREGROUND_SERVICE_DATA_SYNC permission below. -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<!-- Receiver classes live at the package root (NOT under .widgets) because
|
||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||
<receiver
|
||||
@@ -133,22 +104,10 @@
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- Android 13+ runtime notification permission. Requested at login via
|
||||
FirebaseMessaging.requestPermission(); without this declaration the
|
||||
locally rendered push notifications are silently dropped. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- background_downloader foreground service for large/long downloads that
|
||||
must keep running while the app is backgrounded. DATA_SYNC is the
|
||||
required type declaration on Android 14+ (API 34+). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
||||
reboot, otherwise the widget freezes until the user opens the app. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- Aus open_filex gemergt: die App öffnet nur selbst heruntergeladene Dateien
|
||||
über dessen FileProvider (content://-URIs) und braucht keinen Zugriff auf
|
||||
geteilte Medien. Entfernt wegen Google-Play "Photo and Video Permissions". -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove"/>
|
||||
<!-- RMV "in meiner Nähe"-Suche. Coarse reicht (RMV-Suchradius >= 500 m). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
</manifest>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Status-bar small icon: monochrome Marianum M, ported from
|
||||
marianum_m_watermark.xml (same path + nested transforms). Android renders
|
||||
only the alpha channel of small icons, so the fill is hard white and never
|
||||
tinted at runtime. 24dp with the source's non-square viewport — the system
|
||||
fits it into the icon bounds.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="70.000168"
|
||||
android:viewportHeight="82.227348">
|
||||
|
||||
<group
|
||||
android:scaleX="0.26458333"
|
||||
android:scaleY="0.26458334"
|
||||
android:translateX="107.44411"
|
||||
android:translateY="-80.482198">
|
||||
<group
|
||||
android:translateX="-749.41293"
|
||||
android:translateY="290.52252">
|
||||
<group
|
||||
android:scaleX="0.13333333"
|
||||
android:scaleY="-0.13333333"
|
||||
android:translateX="0"
|
||||
android:translateY="632.14667">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Brand accent for notifications; mirrors LightAppTheme.marianumRed
|
||||
(Color.fromARGB(255, 153, 51, 51)). Used as the FCM default
|
||||
notification color and must stay in sync with the Dart constant. -->
|
||||
<color name="notification_accent">#993333</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Allow cleartext for the MarianumConnect test instance only. Once the
|
||||
production URL with HTTPS is live, drop this domain-config entry. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">muelleel.ddns.net</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -22,11 +22,6 @@ subprojects { sub ->
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
// Some plugins (e.g. app_settings) hard-pin an older compileSdk
|
||||
// than their transitive AndroidX deps require, which fails the
|
||||
// AAR metadata check. Align every plugin with the app's
|
||||
// compileSdk (flutter.compileSdkVersion) so the build passes.
|
||||
compileSdkVersion 36
|
||||
}
|
||||
}
|
||||
sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package_name("eu.mhsl.marianum.mobile.client")
|
||||
|
||||
# Service-Account-JSON für die Google Play Developer API. Pfad über die
|
||||
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
|
||||
# .gitignore nicht eingecheckt).
|
||||
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
|
||||
@@ -1,29 +0,0 @@
|
||||
default_platform(:android)
|
||||
|
||||
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
|
||||
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
|
||||
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
|
||||
# metadata/android/<locale>/.
|
||||
platform :android do
|
||||
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
|
||||
lane :upload_screenshots do
|
||||
upload_to_play_store(
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
skip_upload_metadata: true,
|
||||
skip_upload_changelogs: true,
|
||||
skip_upload_images: false,
|
||||
skip_upload_screenshots: false,
|
||||
metadata_path: "./fastlane/metadata/android",
|
||||
)
|
||||
end
|
||||
|
||||
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
|
||||
lane :upload_metadata do
|
||||
upload_to_play_store(
|
||||
skip_upload_apk: true,
|
||||
skip_upload_aab: true,
|
||||
metadata_path: "./fastlane/metadata/android",
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
|
||||
# Plugin-Handling initialisiert.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Fastlane – Play-Store-Screenshots & Upload
|
||||
|
||||
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
|
||||
gebaut werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
|
||||
aus Fixtures.
|
||||
|
||||
## 1. Screenshots aufnehmen
|
||||
|
||||
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
|
||||
**Client**-Wurzelverzeichnis:
|
||||
|
||||
```bash
|
||||
tool/screenshots.sh
|
||||
```
|
||||
|
||||
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
|
||||
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
|
||||
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
|
||||
|
||||
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
|
||||
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
|
||||
|
||||
## 2. Hochladen
|
||||
|
||||
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
|
||||
|
||||
```bash
|
||||
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
|
||||
cd android
|
||||
fastlane upload_screenshots # nur Bilder
|
||||
# oder
|
||||
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
|
||||
```
|
||||
|
||||
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
|
||||
@@ -4,7 +4,3 @@ 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
|
||||
|
||||
+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-8.13-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,40 +0,0 @@
|
||||
---
|
||||
#
|
||||
# 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)
|
||||
@@ -1,215 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:marianum_mobile/app.dart';
|
||||
import 'package:marianum_mobile/main.dart' as app;
|
||||
import 'package:marianum_mobile/routing/app_routes.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
|
||||
|
||||
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
|
||||
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
|
||||
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
||||
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
||||
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
||||
/// (Phone / 7″ / 10″).
|
||||
///
|
||||
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
||||
/// Fixtures, daher ist der Lauf deterministisch.
|
||||
Future<void> main() async {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
|
||||
// Der integration_test-Harness installiert eigene Error-Handler. app()
|
||||
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
|
||||
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
|
||||
// Daher vorher sichern und nach main() zurücksetzen.
|
||||
final harnessFlutterOnError = FlutterError.onError;
|
||||
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
|
||||
|
||||
_log('main() starten');
|
||||
await app.main();
|
||||
|
||||
FlutterError.onError = harnessFlutterOnError;
|
||||
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
_log('Login');
|
||||
await _login(tester);
|
||||
|
||||
_log('warte auf App-Shell');
|
||||
await _pumpUntil(
|
||||
tester,
|
||||
find.byType(App),
|
||||
timeout: const Duration(seconds: 20),
|
||||
);
|
||||
_log('warte auf Post-Login-Splash-Ende');
|
||||
await _pumpUntilGone(
|
||||
tester,
|
||||
find.byKey(const ValueKey('post-login-splash')),
|
||||
timeout: const Duration(seconds: 12),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
// Screenshots auf Android brauchen eine einmalige Umwandlung der
|
||||
// Flutter-Surface in eine lesbare Image-Texture. Erst hier – nach Login und
|
||||
// Splash –, damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
|
||||
if (Platform.isAndroid) {
|
||||
_log('convertFlutterSurfaceToImage');
|
||||
await binding.convertFlutterSurfaceToImage();
|
||||
}
|
||||
|
||||
// 1 + 2: Stundenplan in beiden Themes.
|
||||
await _goTo(tester, Modules.timetable);
|
||||
_setTheme(tester, ThemeMode.light);
|
||||
await _capture(tester, binding, '1_stundenplan_hell');
|
||||
_setTheme(tester, ThemeMode.dark);
|
||||
await _capture(tester, binding, '2_stundenplan_dunkel');
|
||||
|
||||
// Restlicher Satz einheitlich im hellen Theme.
|
||||
_setTheme(tester, ThemeMode.light);
|
||||
|
||||
// 3: Talk-Liste.
|
||||
await _goTo(tester, Modules.talk);
|
||||
await _capture(tester, binding, '3_talk_liste');
|
||||
|
||||
// 4: Talk-Chat (ersten Chat der Liste öffnen).
|
||||
await tester.tap(find.byType(ChatTile).first);
|
||||
await _capture(tester, binding, '4_talk_chat');
|
||||
_popPushedPage(tester);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// 5: Dateien.
|
||||
await _goTo(tester, Modules.files);
|
||||
await _capture(tester, binding, '5_dateien');
|
||||
|
||||
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
|
||||
_goToMore(tester);
|
||||
await _capture(tester, binding, '6_mehr');
|
||||
|
||||
// 7: Einstellungen (Vollbild-Push).
|
||||
AppRoutes.openSettings(tester.element(find.byType(App)));
|
||||
await _capture(tester, binding, '7_einstellungen');
|
||||
_popPushedPage(tester);
|
||||
|
||||
_log('fertig');
|
||||
});
|
||||
}
|
||||
|
||||
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')),
|
||||
);
|
||||
if (!loginVisible) {
|
||||
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
||||
return;
|
||||
}
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('login-username-field')),
|
||||
'demo@screenshots',
|
||||
);
|
||||
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const Key('login-submit-button')));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
|
||||
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
|
||||
void _setTheme(WidgetTester tester, ThemeMode mode) {
|
||||
final context = tester.element(find.byType(App));
|
||||
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
||||
}
|
||||
|
||||
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
||||
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
||||
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
||||
final appFinder = find.byType(App);
|
||||
if (appFinder.evaluate().isEmpty) {
|
||||
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
|
||||
return;
|
||||
}
|
||||
final context = tester.element(appFinder);
|
||||
if (!AppRoutes.goToTab(context, module)) {
|
||||
final resolved = AppModule.modules(context)[module];
|
||||
if (resolved != null) AppRoutes.openModule(context, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
|
||||
/// also die Anzahl der Bottom-Bar-Module.
|
||||
void _goToMore(WidgetTester tester) {
|
||||
final context = tester.element(find.byType(App));
|
||||
final moreIndex = AppModule.getBottomBarModules(context).length;
|
||||
app.Main.bottomNavigator.jumpToTab(moreIndex);
|
||||
}
|
||||
|
||||
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
|
||||
void _popPushedPage(WidgetTester tester) {
|
||||
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
||||
if (navigator != null && navigator.canPop()) navigator.pop();
|
||||
}
|
||||
|
||||
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
|
||||
Future<void> _capture(
|
||||
WidgetTester tester,
|
||||
IntegrationTestWidgetsFlutterBinding binding,
|
||||
String name,
|
||||
) async {
|
||||
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
|
||||
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
|
||||
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
|
||||
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
|
||||
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
|
||||
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
|
||||
await _settle(tester, const Duration(seconds: 6));
|
||||
_log('-> $name: takeScreenshot');
|
||||
await binding.takeScreenshot(name);
|
||||
}
|
||||
|
||||
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
|
||||
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
|
||||
/// Dauer-Animationen hängen bleibt).
|
||||
Future<void> _settle(WidgetTester tester, Duration duration) async {
|
||||
final steps = duration.inMilliseconds ~/ 500;
|
||||
for (var i = 0; i < steps; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
|
||||
/// Gibt zurück, ob [finder] gefunden wurde.
|
||||
Future<bool> _pumpUntil(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
if (finder.evaluate().isNotEmpty) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
|
||||
Future<bool> _pumpUntilGone(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
if (finder.evaluate().isEmpty) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>NotificationServiceExtension</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,256 +0,0 @@
|
||||
import UserNotifications
|
||||
import Security
|
||||
|
||||
/// Notification Service Extension for Nextcloud push-v2.
|
||||
///
|
||||
/// Architecture: Nextcloud pushes an E2E-encrypted notification to the
|
||||
/// MarianumConnect proxy, which forwards it via FCM to this device as an
|
||||
/// `alert` push with `mutable-content: 1`. That flag makes iOS spin up this
|
||||
/// extension *before* showing the notification, giving us ~30 s (wall clock,
|
||||
/// shared with the CPU budget) to decrypt the payload and replace the
|
||||
/// placeholder alert with the real content.
|
||||
///
|
||||
/// The FCM proxy places the two relevant fields at the top level of the APNs
|
||||
/// payload (mirrored into `request.content.userInfo`):
|
||||
/// - `subject` base64( RSA-encrypted subject JSON ) — encrypted with THIS
|
||||
/// device's public key, so we decrypt with our private key.
|
||||
/// - `signature` base64( SHA-512-with-RSA over the *encrypted* subject bytes )
|
||||
/// — signed by the per-user server key we stored at registration.
|
||||
///
|
||||
/// Key material lives in the shared (App Group) keychain, written by the Dart
|
||||
/// side via flutter_secure_storage with
|
||||
/// `IOSOptions(groupId: kPushKeychainGroup, accessibility: first_unlock)`:
|
||||
/// - `push_device_private_key_pem` crypton PKCS#8-in-"RSA PRIVATE KEY" PEM
|
||||
/// - `push_server_public_key_pem` SPKI "PUBLIC KEY" PEM (from NC)
|
||||
///
|
||||
/// Robustness contract: on ANY failure (missing keys, bad signature, decrypt
|
||||
/// failure, malformed JSON, timeout) we deliver the untouched placeholder
|
||||
/// content. We never crash and never deliver empty content.
|
||||
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 = "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"
|
||||
|
||||
private static let talkCategoryId = "TALK_MESSAGE"
|
||||
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var bestAttemptContent: UNMutableNotificationContent?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
self.bestAttemptContent =
|
||||
request.content.mutableCopy() as? UNMutableNotificationContent
|
||||
|
||||
guard let content = bestAttemptContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
// Any early return delivers the placeholder unchanged — that is the
|
||||
// intended fallback, never an error path that hides the notification.
|
||||
let userInfo = content.userInfo
|
||||
guard
|
||||
let subjectB64 = userInfo["subject"] as? String,
|
||||
let signatureB64 = userInfo["signature"] as? String,
|
||||
let encrypted = Data(base64Encoded: subjectB64),
|
||||
let signature = Data(base64Encoded: signatureB64)
|
||||
else {
|
||||
deliver(content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let privateKey = loadPrivateKey() else {
|
||||
NSLog("[NSE] no device private key in keychain, delivering placeholder")
|
||||
deliver(content)
|
||||
return
|
||||
}
|
||||
|
||||
// Signature verification is defence-in-depth (the proxy already verified
|
||||
// it). Skip only if we somehow have no server key; never deliver on a
|
||||
// *failed* verification.
|
||||
if let serverKey = loadServerPublicKey() {
|
||||
if !verifySignature(signature, over: encrypted, with: serverKey) {
|
||||
NSLog("[NSE] signature verification failed, delivering placeholder")
|
||||
deliver(content)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard
|
||||
let plaintext = decrypt(encrypted, with: privateKey),
|
||||
let subject = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any]
|
||||
else {
|
||||
NSLog("[NSE] could not decrypt/parse subject, delivering placeholder")
|
||||
deliver(content)
|
||||
return
|
||||
}
|
||||
|
||||
apply(subject: subject, to: content)
|
||||
deliver(content)
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
// Ran out of time — hand back whatever we have (at worst the placeholder).
|
||||
if let handler = contentHandler, let content = bestAttemptContent {
|
||||
handler(content)
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ content: UNNotificationContent) {
|
||||
contentHandler?(content)
|
||||
}
|
||||
|
||||
// MARK: - Content shaping
|
||||
|
||||
private func apply(subject: [String: Any], to content: UNMutableNotificationContent) {
|
||||
// Delete pushes normally arrive as silent `background` pushes that never
|
||||
// invoke this extension. If one reaches us anyway, an NSE cannot suppress
|
||||
// the banner (iOS always shows *something*), so we leave the placeholder.
|
||||
// Actual delete cleanup is done by the Flutter app on next open. See
|
||||
// ios/PUSH_NSE_SETUP.md "Delete-Handling".
|
||||
let isDelete = (subject["delete"] as? Bool == true)
|
||||
|| (subject["delete-multiple"] as? Bool == true)
|
||||
|| (subject["delete-all"] as? Bool == true)
|
||||
if isDelete {
|
||||
NSLog("[NSE] delete push reached NSE — cannot suppress, leaving placeholder")
|
||||
return
|
||||
}
|
||||
|
||||
let app = subject["app"] as? String
|
||||
let text = subject["subject"] as? String ?? "Neue Benachrichtigung"
|
||||
let objectId = stringValue(subject["id"])
|
||||
let nid = subject["nid"]
|
||||
|
||||
if app == "spreed" {
|
||||
// Talk: "Sender: message" -> title = Sender, body = message.
|
||||
let (sender, message) = splitSender(text)
|
||||
content.title = sender
|
||||
content.body = message
|
||||
content.categoryIdentifier = NotificationService.talkCategoryId
|
||||
if let token = objectId, !token.isEmpty {
|
||||
content.threadIdentifier = "talk_\(token)"
|
||||
var info = content.userInfo
|
||||
info["chatToken"] = token
|
||||
if let nid = nid { info["nid"] = nid }
|
||||
content.userInfo = info
|
||||
}
|
||||
} else {
|
||||
content.title = text
|
||||
content.body = ""
|
||||
}
|
||||
// Badge intentionally left untouched (the app recomputes it from the
|
||||
// delivered notifications).
|
||||
}
|
||||
|
||||
/// Splits a Talk subject into header and message. nextcloud/spreed's
|
||||
/// Notifier separates them with a NEWLINE — `"{user}\n{message}"` for 1:1
|
||||
/// chats, `"{user} in {call}\n{message}"` for groups (verified against
|
||||
/// spreed; mirrors parseTalkSubject in lib/push/push_subject.dart). The
|
||||
/// legacy `": "` form is kept as fallback for older payloads.
|
||||
private func splitSender(_ subject: String) -> (String, String) {
|
||||
if let range = subject.range(of: "\n") {
|
||||
let sender = String(subject[..<range.lowerBound])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let message = String(subject[range.upperBound...])
|
||||
if !sender.isEmpty && !message.isEmpty {
|
||||
return (sender, message)
|
||||
}
|
||||
}
|
||||
if let range = subject.range(of: ": ") {
|
||||
let sender = String(subject[..<range.lowerBound])
|
||||
let message = String(subject[range.upperBound...])
|
||||
if !sender.isEmpty && !message.isEmpty {
|
||||
return (sender, message)
|
||||
}
|
||||
}
|
||||
return ("Talk", subject)
|
||||
}
|
||||
|
||||
private func stringValue(_ value: Any?) -> String? {
|
||||
if let s = value as? String { return s }
|
||||
if let n = value as? NSNumber { return n.stringValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Crypto
|
||||
|
||||
private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool {
|
||||
var error: Unmanaged<CFError>?
|
||||
let ok = SecKeyVerifySignature(
|
||||
key,
|
||||
.rsaSignatureMessagePKCS1v15SHA512,
|
||||
message as CFData,
|
||||
signature as CFData,
|
||||
&error
|
||||
)
|
||||
return ok
|
||||
}
|
||||
|
||||
private func decrypt(_ data: Data, with key: SecKey) -> Data? {
|
||||
// NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try
|
||||
// OAEP first, fall back to PKCS#1 — mirrors the Dart PushDecryptor.
|
||||
for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1,
|
||||
SecKeyAlgorithm.rsaEncryptionPKCS1] {
|
||||
var error: Unmanaged<CFError>?
|
||||
if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? {
|
||||
return plain
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Key loading
|
||||
|
||||
private func loadPrivateKey() -> SecKey? {
|
||||
guard let pem = keychainString(NotificationService.devicePrivateKeyAccount),
|
||||
let der = PEM.der(fromPem: pem),
|
||||
// crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8.
|
||||
let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der)
|
||||
else { return nil }
|
||||
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true)
|
||||
}
|
||||
|
||||
private func loadServerPublicKey() -> SecKey? {
|
||||
guard let pem = keychainString(NotificationService.serverPublicKeyAccount),
|
||||
let der = PEM.der(fromPem: pem),
|
||||
let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der)
|
||||
else { return nil }
|
||||
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
/// Reads a flutter_secure_storage entry from the shared keychain. Must match
|
||||
/// exactly how flutter_secure_storage_darwin 0.3.2 stores items:
|
||||
/// kSecClass = kSecClassGenericPassword
|
||||
/// kSecAttrAccount = the Dart key, verbatim
|
||||
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
||||
/// 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] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: NotificationService.keychainAccessGroup,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
if status != errSecItemNotFound {
|
||||
NSLog("[NSE] keychain read '\(account)' failed: \(status)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,105 +0,0 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Minimal PEM/DER helpers to turn the crypton-produced PEM strings into
|
||||
/// `SecKey`s. iOS's `SecKeyCreateWithData` only accepts *raw PKCS#1* DER for
|
||||
/// RSA, so we must strip the two wrapper formats crypton/Nextcloud use:
|
||||
///
|
||||
/// - Private key: crypton's `toPEM()` emits the `RSA PRIVATE KEY` label but the
|
||||
/// DER body is actually **PKCS#8** `PrivateKeyInfo`
|
||||
/// (SEQUENCE { INTEGER version, SEQUENCE algId, OCTET STRING pkcs1 }).
|
||||
/// We extract the OCTET STRING content = the PKCS#1 `RSAPrivateKey`.
|
||||
///
|
||||
/// - Public key: `PUBLIC KEY` label, **SPKI** `SubjectPublicKeyInfo`
|
||||
/// (SEQUENCE { SEQUENCE algId, BIT STRING spki }). We extract the BIT STRING
|
||||
/// content and drop its leading 0x00 "unused bits" byte = the PKCS#1
|
||||
/// `RSAPublicKey`.
|
||||
///
|
||||
/// Only DER lengths up to 4 bytes are handled — more than enough for RSA-2048.
|
||||
enum PEM {
|
||||
|
||||
/// Strips the PEM armor and returns the base64-decoded DER.
|
||||
static func der(fromPem pem: String) -> Data? {
|
||||
let body = pem
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.filter { !$0.hasPrefix("-----") }
|
||||
.joined()
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
|
||||
/// PKCS#8 `PrivateKeyInfo` -> inner PKCS#1 `RSAPrivateKey`.
|
||||
static func pkcs1PrivateKey(fromPkcs8 der: Data) -> Data? {
|
||||
var reader = DERReader(der)
|
||||
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
|
||||
var inner = DERReader(Data(outer.value))
|
||||
guard let version = inner.readTLV(), version.tag == 0x02 else { return nil } // INTEGER version
|
||||
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
|
||||
guard let octet = inner.readTLV(), octet.tag == 0x04 else { return nil } // OCTET STRING
|
||||
return Data(octet.value)
|
||||
}
|
||||
|
||||
/// SPKI `SubjectPublicKeyInfo` -> inner PKCS#1 `RSAPublicKey`.
|
||||
static func pkcs1PublicKey(fromSpki der: Data) -> Data? {
|
||||
var reader = DERReader(der)
|
||||
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
|
||||
var inner = DERReader(Data(outer.value))
|
||||
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
|
||||
guard let bitString = inner.readTLV(), bitString.tag == 0x03 else { return nil } // BIT STRING
|
||||
var bytes = Array(bitString.value)
|
||||
guard let first = bytes.first, first == 0x00 else { return nil } // unused-bits count
|
||||
bytes.removeFirst()
|
||||
return Data(bytes)
|
||||
}
|
||||
|
||||
/// Builds an RSA `SecKey` from raw PKCS#1 DER.
|
||||
static func rsaKey(pkcs1 der: Data, isPrivate: Bool) -> SecKey? {
|
||||
let attributes: [CFString: Any] = [
|
||||
kSecAttrKeyType: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass: isPrivate ? kSecAttrKeyClassPrivate : kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits: 2048,
|
||||
]
|
||||
var error: Unmanaged<CFError>?
|
||||
let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error)
|
||||
if key == nil {
|
||||
NSLog("[NSE] SecKeyCreateWithData failed: \(String(describing: error?.takeRetainedValue()))")
|
||||
}
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny non-recursive DER TLV reader over a byte buffer.
|
||||
private struct DERReader {
|
||||
private let bytes: [UInt8]
|
||||
private var pos = 0
|
||||
|
||||
init(_ data: Data) { bytes = [UInt8](data) }
|
||||
|
||||
/// Reads one tag-length-value triple, advancing past the value. Returns the
|
||||
/// tag byte and the value bytes, or nil on a malformed/truncated buffer.
|
||||
mutating func readTLV() -> (tag: UInt8, value: ArraySlice<UInt8>)? {
|
||||
guard pos < bytes.count else { return nil }
|
||||
let tag = bytes[pos]
|
||||
pos += 1
|
||||
|
||||
guard pos < bytes.count else { return nil }
|
||||
var length = Int(bytes[pos])
|
||||
pos += 1
|
||||
|
||||
if length & 0x80 != 0 {
|
||||
let numLengthBytes = length & 0x7F
|
||||
guard numLengthBytes > 0, numLengthBytes <= 4, pos + numLengthBytes <= bytes.count else {
|
||||
return nil
|
||||
}
|
||||
length = 0
|
||||
for _ in 0..<numLengthBytes {
|
||||
length = (length << 8) | Int(bytes[pos])
|
||||
pos += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard pos + length <= bytes.count else { return nil }
|
||||
let value = bytes[pos..<pos + length]
|
||||
pos += length
|
||||
return (tag, value)
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
# iOS Notification Service Extension (NSE) — Setup & Übergabe
|
||||
|
||||
> **Für wen:** Eine spätere (kontextlose) Claude-Session **und** den Menschen, der
|
||||
> auf einem Mac mit Xcode weiterbaut. Dieses Dokument ist die einzige Quelle der
|
||||
> Wahrheit für die iOS-Push-Fertigstellung. Die Swift-/Dart-Dateien existieren
|
||||
> bereits (auf Linux textuell erstellt, **nie kompiliert**). Was fehlt, ist die
|
||||
> reine Xcode-Verdrahtung (Target anlegen, Capabilities, Signing) — das geht
|
||||
> **nur** auf dem Mac.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur-Kurzfassung
|
||||
|
||||
```
|
||||
Nextcloud (cloud.marianum-fulda.de)
|
||||
│ push-v2: E2E-verschlüsselte Notification (mit Device-Public-Key verschlüsselt)
|
||||
▼
|
||||
MarianumConnect Push-Proxy (connect.marianum-fulda.de/push-proxy/notifications)
|
||||
│ verifiziert Signatur, leitet per FCM HTTP v1 weiter
|
||||
▼
|
||||
FCM → APNs → iOS-Gerät
|
||||
│ alert-Push mit `mutable-content: 1`, `subject` + `signature` top-level im Payload
|
||||
▼
|
||||
NotificationServiceExtension (DIESES Target)
|
||||
│ liest Device-Private-Key + Server-Public-Key aus dem geteilten Keychain
|
||||
│ verifiziert Signatur → entschlüsselt subject → setzt Titel/Body/Category
|
||||
▼
|
||||
iOS zeigt die fertige Notification
|
||||
```
|
||||
|
||||
- **Verschlüsselung:** OAEP-SHA1 (NC-32-Default) mit Fallback PKCS#1 v1.5.
|
||||
- **Signatur:** `SHA512withRSA` über die **base64-dekodierten (verschlüsselten)**
|
||||
subject-Bytes, geprüft mit dem per-User-Server-Public-Key aus der Registrierung.
|
||||
- **Dart-Referenz:** `lib/push/` — insb. `push_decryptor.dart` (Krypto-Spiegel),
|
||||
`push_secure_storage.dart` (Keychain-Optionen), `push_keypair.dart` (Keyformat),
|
||||
`push_registration_store.dart` (was group-scoped gespeichert wird),
|
||||
`push_registration.dart` (`_persistNativeAuthContext`).
|
||||
- **Backend-Referenz:** MarianumConnect-Service `marMobileApi`, Endpunkte
|
||||
`push-proxy/notifications` (NC→Proxy) und `PUT/DELETE me/push-device`.
|
||||
- **Talk-Actions (Antworten / Als gelesen markieren):** werden **nativ** im
|
||||
`AppDelegate` per `URLSession` an die Talk-OCS-API geschickt, weil bei einer
|
||||
Notification-Action nicht garantiert ist, dass die Flutter-Engine läuft.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dateiinventar
|
||||
|
||||
| Datei | Status | Anmerkung |
|
||||
|-------|--------|-----------|
|
||||
| `ios/NotificationServiceExtension/NotificationService.swift` | **existiert** | NSE-Hauptklasse (entschlüsseln + Content setzen) |
|
||||
| `ios/NotificationServiceExtension/PEM.swift` | **existiert** | PEM/DER→`SecKey` (PKCS#8/SPKI-Stripping) |
|
||||
| `ios/NotificationServiceExtension/Info.plist` | **existiert** | `NSExtensionPointIdentifier = com.apple.usernotifications.service` |
|
||||
| `ios/NotificationServiceExtension/NotificationServiceExtension.entitlements` | **existiert** | App Group + keychain-access-groups |
|
||||
| `ios/Runner/Runner.entitlements` | **geändert** | `keychain-access-groups` (widget-Group) ergänzt |
|
||||
| `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"** | **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
|
||||
> Platzhalter-Dateien — **diese löschen und die vorhandenen Dateien zuordnen**
|
||||
> (siehe 3.2), sonst überschreibst du die fertige Implementierung.
|
||||
|
||||
---
|
||||
|
||||
## 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**.
|
||||
3. **Product Name exakt: `NotificationServiceExtension`** (der
|
||||
`NSExtensionPrincipalClass` in der Info.plist ist
|
||||
`$(PRODUCT_MODULE_NAME).NotificationService` — bei abweichendem Namen zerbricht
|
||||
das). Language: Swift. „Embed in Application": Runner.
|
||||
4. Beim Dialog „Activate scheme?" → Activate.
|
||||
5. **Bundle Identifier:** `eu.mhsl.marianum.mobile.client.NotificationServiceExtension`
|
||||
(Muster wie bei den bestehenden Extensions).
|
||||
6. **Deployment Target = 15.0** (identisch zum Runner; im `post_install` des
|
||||
Podfiles wird ohnehin alles auf 15.0 gezwungen).
|
||||
|
||||
### 3.2 Generierte Dateien durch die vorhandenen ersetzen
|
||||
- Xcode legt eine eigene `NotificationService.swift` und `Info.plist` im
|
||||
Target-Ordner an. **Beide aus dem Projekt entfernen** („Move to Trash" für die
|
||||
frisch generierten) und stattdessen die bereits vorhandenen Dateien hinzufügen:
|
||||
- `NotificationService.swift`, `PEM.swift` → **Target Membership: nur NSE**.
|
||||
- `Info.plist` → in den **Build Settings** des NSE-Targets als
|
||||
`INFOPLIST_FILE = NotificationServiceExtension/Info.plist` setzen (bzw. die
|
||||
vorhandene Datei als Info.plist des Targets referenzieren).
|
||||
- `NotificationServiceExtension.entitlements` → in Build Settings
|
||||
`CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements`.
|
||||
|
||||
### 3.3 Capabilities (in **beiden** Targets: Runner **und** NSE)
|
||||
- **App Groups:** `group.eu.mhsl.marianum.mobile.client.widget` aktivieren.
|
||||
(Beim Runner ist die Group bereits vorhanden; beim NSE neu hinzufügen.)
|
||||
- **Keychain Sharing:** Keychain-Group `group.eu.mhsl.marianum.mobile.client.widget`
|
||||
hinzufügen. Die Entitlement-Dateien enthalten das schon als
|
||||
`keychain-access-groups`; über die Capabilities-UI stellst du sicher, dass das
|
||||
Provisioning-Profil es abdeckt.
|
||||
- Prüfen, dass die Capabilities-UI **keine** doppelten/abweichenden Einträge
|
||||
erzeugt hat (Xcode schreibt gern in die `.entitlements`; danach Abschnitt 4
|
||||
gegenchecken).
|
||||
|
||||
### 3.4 Signing
|
||||
- Beide Targets: **Team** = dasselbe wie Runner. Automatic Signing an, oder die
|
||||
passenden Profile wählen. Der App-Identifier-Prefix (Team-ID) muss identisch
|
||||
sein, sonst greift die Keychain-Group nicht.
|
||||
|
||||
### 3.5 Pods / Podfile
|
||||
- Die NSE ist **komplett selbst-enthalten** (`Foundation`, `Security`,
|
||||
`UserNotifications` — alles System-Frameworks). **Kein** Pod-Target nötig.
|
||||
- Prüfen, ob irgendein Flutter-Plugin explizit ein NSE-Target im Podfile verlangt
|
||||
(aktuell **nein** — `firebase_messaging` braucht keins). Falls doch mal nötig:
|
||||
im Podfile analog zur `Share Extension` einen `target
|
||||
'NotificationServiceExtension' do inherit! :search_paths end`-Block ergänzen,
|
||||
danach `pod install`.
|
||||
- Nach dem Anlegen einmal `cd ios && pod install` laufen lassen (aktualisiert die
|
||||
Workspace-Referenzen).
|
||||
|
||||
### 3.6 aps-environment (Production!)
|
||||
- `ios/Runner/Runner.entitlements` hat aktuell `aps-environment = development`.
|
||||
**Für TestFlight/Release muss `production` aktiv sein.** Das wurde hier bewusst
|
||||
**nicht** hart umgestellt, um den Debug-Flow nicht zu brechen.
|
||||
- Empfehlung: über die **Capabilities → Push Notifications** und die
|
||||
Build-Konfiguration steuern (Xcode setzt beim Archive automatisch `production`,
|
||||
wenn das Profil ein Distribution-Profil ist). Beim Archive-Export kontrollieren,
|
||||
dass in der finalen `.ipa` `aps-environment = production` steht (siehe 5.2).
|
||||
|
||||
---
|
||||
|
||||
## 4. Ermittelte Keychain-Details (verbindlich)
|
||||
|
||||
Die Dart-Seite schreibt mit
|
||||
`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:
|
||||
|
||||
| Keychain-Attribut | Wert |
|
||||
|-------------------|------|
|
||||
| `kSecClass` | `kSecClassGenericPassword` |
|
||||
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
||||
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
||||
| `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) |
|
||||
|
||||
**Deshalb** fragt die Swift-Seite (`keychainString(...)` in NSE **und**
|
||||
AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
|
||||
`kSecAttrAccessGroup` + `kSecMatchLimit=One` + `kSecReturnData=true`, **ohne**
|
||||
`kSecAttrService`. (Ein gesetztes `kSecAttrService` würde nicht matchen.)
|
||||
|
||||
### Konkrete Einträge (Account-Namen)
|
||||
|
||||
| Account (`kSecAttrAccount`) | Inhalt | Geschrieben von | Genutzt von |
|
||||
|-----------------------------|--------|-----------------|-------------|
|
||||
| `push_device_private_key_pem` | Device-**Private**-Key, PEM | `push_keypair.dart` | **NSE** (entschlüsseln) |
|
||||
| `push_device_public_key_pem` | Device-Public-Key, SPKI-PEM | `push_keypair.dart` | — (nur Registrierung) |
|
||||
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
||||
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
||||
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
||||
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
|
||||
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
||||
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
||||
|
||||
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
|
||||
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||
> App-Password lag schon immer group-scoped.
|
||||
|
||||
### Schlüsselformate (kritisch für das PEM-Parsing)
|
||||
|
||||
`crypton` (2.2.1) erzeugt die Keys:
|
||||
- **Private Key:** Label `-----BEGIN RSA PRIVATE KEY-----`, der DER-Body ist aber
|
||||
**PKCS#8** `PrivateKeyInfo` (SEQ{ INTEGER version, SEQ algId, OCTET STRING
|
||||
pkcs1 }) — **nicht** rohes PKCS#1! `SecKeyCreateWithData` will bei RSA aber
|
||||
**rohes PKCS#1**. `PEM.pkcs1PrivateKey(fromPkcs8:)` strippt den PKCS#8-Wrapper
|
||||
(extrahiert den OCTET-STRING-Inhalt).
|
||||
- **Public Key** (Device **und** der von NC gelieferte Server-Key): Label
|
||||
`-----BEGIN PUBLIC KEY-----`, DER = **SPKI** `SubjectPublicKeyInfo`.
|
||||
`PEM.pkcs1PublicKey(fromSpki:)` extrahiert den BIT-STRING-Inhalt und wirft das
|
||||
führende `0x00`-Byte (unused bits) weg → PKCS#1 `RSAPublicKey`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verifikation auf dem Mac
|
||||
|
||||
### 5.1 Build
|
||||
- `flutter clean && flutter pub get`
|
||||
- `cd ios && pod install`
|
||||
- In Xcode: **beide** Targets bauen. NSE-Target-Scheme separat bauen, um Swift-
|
||||
Fehler früh zu sehen.
|
||||
- Auf echtem Gerät installieren (Push funktioniert **nicht** im Simulator).
|
||||
|
||||
### 5.2 aps-environment im Archive prüfen
|
||||
- Product → Archive → Distribute (oder `.ipa` exportieren) → `.ipa` entpacken →
|
||||
`codesign -d --entitlements :- Payload/Runner.app` → muss
|
||||
`aps-environment = production` zeigen.
|
||||
|
||||
### 5.3 Testbenachrichtigung (schneller Smoke-Test, ohne NC-Krypto)
|
||||
- In den App-Einstellungen → Benachrichtigungen → **Testbenachrichtigung**
|
||||
(Backend `POST me/push-device/test`). Erwartung: Alert „Testbenachrichtigung /
|
||||
Push-Benachrichtigungen funktionieren! 🎉". Testet Registrierung + FCM +
|
||||
Rendering — die NSE ist hier **nicht** beteiligt (Connect-Push, kein
|
||||
`mutable-content`).
|
||||
|
||||
### 5.4 Echte Talk-Nachricht (End-to-End inkl. NSE)
|
||||
- Gerät sperren. Von einem Zweitaccount eine Talk-Nachricht schicken.
|
||||
- **Erwartung:** Banner mit **echtem Absender + Nachrichtentext** (nicht der
|
||||
Platzhalter „Neue Benachrichtigung / Tippen zum Öffnen"). Long-press →
|
||||
Actions „Antworten" + „Als gelesen markieren".
|
||||
- „Antworten" → Text senden → Nachricht erscheint im Chat (nativ via URLSession),
|
||||
Chat wird als gelesen markiert.
|
||||
- Alternativ auf der NC-Instanz: `occ notification:test-push --talk <user> -v`.
|
||||
|
||||
### 5.5 Woran erkenne ich, ob die NSE lief?
|
||||
- **NSE lief & OK:** echter Absender/Text im Banner.
|
||||
- **NSE lief nicht / Fehler:** Platzhalter-Text bleibt stehen. Das ist der
|
||||
bewusste Fallback (nie leer, nie Crash).
|
||||
- **Logs:** Über die macOS-**Console.app** das Gerät wählen und nach `[NSE]` bzw.
|
||||
`[Talk action]` filtern (`NSLog`-Ausgaben). Oder in Xcode: Debug → Attach to
|
||||
Process → `NotificationServiceExtension`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bekannte Fehlerbilder + Fixes
|
||||
|
||||
| Symptom | Wahrscheinliche Ursache | Fix |
|
||||
|---------|-------------------------|-----|
|
||||
| NSE greift gar nicht (immer Platzhalter, auch bei Talk) | `mutable-content` fehlt / Signing des NSE-Targets kaputt / Bundle-ID falsch | Backend-`FcmMessageFactory` setzt `mutable-content:1` (prüfen). NSE-Target-Signing + Provisioning prüfen. Bundle-ID = `…client.NotificationServiceExtension`. |
|
||||
| Banner zeigt Platzhalter trotz Talk | PEM-Parsing/Padding schlägt fehl | Console.app nach `[NSE] SecKeyCreateWithData failed` / `could not decrypt`. Prüfen, dass Private-Key wirklich als PKCS#8-in-„RSA PRIVATE KEY" ankommt (crypton). OAEP↔PKCS1-Fallback ist schon drin. |
|
||||
| `[NSE] no device private key in keychain` | Keychain-Group greift nicht | In **beiden** Targets Keychain Sharing + App Group aktiv? Team-ID identisch? Access-Group-String exakt `group.eu.mhsl.marianum.mobile.client.widget`? |
|
||||
| Signatur schlägt immer fehl | Falscher/kein Server-Public-Key | `push_server_public_key_pem` vorhanden? (wird bei `register()` gesetzt). Notfalls: ohne Server-Key überspringt die NSE die Prüfung — bleibt sie hängen, ist der Key da aber falsch geparst. |
|
||||
| Reply-Action tut nichts | Credentials fehlen / Delegate-Konflikt (s.u.) | Console.app nach `[Talk action]`. `nextcloud_username`/`_app_password`/`_base_url` im Keychain? App einmal neu einloggen (schreibt sie via `register()`). |
|
||||
| Reply wird **doppelt** gesendet | AppDelegate **und** ein Plugin behandeln dieselbe Action | Der AppDelegate returnt bei TALK_REPLY/TALK_MARK_READ **ohne** `super`-Aufruf, das Plugin sieht die Action also nicht. Falls doch doppelt: prüfen, ob `flutter_local_notifications` auf iOS separat als Delegate registriert ist. |
|
||||
|
||||
### ⚠️ Delegate-Ownership (wichtigster Verifikationspunkt)
|
||||
|
||||
`FlutterAppDelegate` konformiert (über `FlutterAppLifeCycleProvider`) zu
|
||||
`UNUserNotificationCenterDelegate` und **leitet** `userNotificationCenter(...)` an
|
||||
die registrierten Plugins weiter (firebase_messaging,
|
||||
flutter_local_notifications). Der `AppDelegate` setzt sich in
|
||||
`didFinishLaunching` als `UNUserNotificationCenter.current().delegate = self`,
|
||||
**überschreibt** die beiden Delegate-Methoden und ruft für alles **außer** den
|
||||
zwei Talk-Actions `super` auf — dadurch bleibt das Plugin-Forwarding intakt. Das
|
||||
ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
|
||||
- Normale Push-Taps müssen weiterhin ins richtige Chat/Ziel navigieren
|
||||
(läuft über `super` → Plugins).
|
||||
- Foreground-Darstellung von FCM darf nicht kaputtgehen (`willPresent` wird hier
|
||||
bewusst **nicht** überschrieben, läuft komplett über `super`/Plugins).
|
||||
- Sollte ein Plugin den Delegate **nach** `didFinishLaunching` erneut übernehmen
|
||||
(und damit unsere Talk-Actions abfangen), das Setzen von
|
||||
`UNUserNotificationCenter.current().delegate = self` an einen späteren Punkt
|
||||
verschieben (z. B. nach dem ersten Dart-Frame). Ohne Gerät nicht abschließend
|
||||
testbar.
|
||||
|
||||
> **Compile-Hinweis:** `override` an den beiden `userNotificationCenter`-Methoden
|
||||
> ist korrekt, weil die Methoden über das von `FlutterAppDelegate` adoptierte
|
||||
> Protokoll `FlutterAppLifeCycleProvider : UNUserNotificationCenterDelegate`
|
||||
> sichtbar sind (verifiziert im Flutter-3.44-Engine-Header). `super.userNotification…`
|
||||
> ist daher aufrufbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Offene TODOs / bewusste Einschränkungen
|
||||
|
||||
1. **Delete-Handling auf iOS (best effort).** Delete-Pushes kommen als silent
|
||||
`background`-Pushes (`content-available`, **kein** `mutable-content`) → die NSE
|
||||
läuft dafür **nicht**. Das Wegräumen erledigt der Flutter-Background-Handler
|
||||
(`PushMessageHandler`) bzw. ein Sweep beim App-Öffnen. Erreicht doch mal ein
|
||||
Delete-Subject die NSE, **kann** sie die Notification **nicht** unterdrücken
|
||||
(eine NSE muss immer *irgendeinen* Content liefern) — sie lässt den Platzhalter
|
||||
stehen und loggt. Kein Fix möglich, dokumentierte iOS-Limitierung.
|
||||
2. **Rich-Detail via OCS (spätere Erweiterung).** v1 lädt **nichts** nach. Optional
|
||||
könnte die NSE bei Talk `GET …/ocs/v2.php/apps/notifications/api/v2/notifications/{nid}`
|
||||
mit dem App-Password abrufen, um Avatar/Rich-Subject anzuzeigen (Timeout < 25 s,
|
||||
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 (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.
|
||||
@@ -46,8 +46,5 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+261
-2
@@ -1,52 +1,311 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
- DKImagePickerController/ImageDataManager (4.3.9)
|
||||
- DKImagePickerController/PhotoGallery (4.3.9):
|
||||
- DKImagePickerController/Core
|
||||
- DKPhotoGallery
|
||||
- DKImagePickerController/Resource (4.3.9)
|
||||
- DKPhotoGallery (0.0.19):
|
||||
- DKPhotoGallery/Core (= 0.0.19)
|
||||
- DKPhotoGallery/Model (= 0.0.19)
|
||||
- DKPhotoGallery/Preview (= 0.0.19)
|
||||
- DKPhotoGallery/Resource (= 0.0.19)
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Core (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Preview
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Model (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Preview (0.0.19):
|
||||
- DKPhotoGallery/Model
|
||||
- DKPhotoGallery/Resource
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- DKPhotoGallery/Resource (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- emoji_picker_flutter (0.0.1):
|
||||
- Flutter
|
||||
- eraser (0.0.1):
|
||||
- Flutter
|
||||
- file_picker (0.0.1):
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- Firebase/CoreOnly (12.12.0):
|
||||
- FirebaseCore (~> 12.12.0)
|
||||
- Firebase/Messaging (12.12.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 12.12.0)
|
||||
- firebase_core (4.7.0):
|
||||
- Firebase/CoreOnly (= 12.12.0)
|
||||
- Flutter
|
||||
- firebase_messaging (16.2.0):
|
||||
- Firebase/Messaging (= 12.12.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- FirebaseCore (12.12.1):
|
||||
- FirebaseCoreInternal (~> 12.12.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
- FirebaseCoreInternal (12.12.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- FirebaseInstallations (12.12.0):
|
||||
- FirebaseCore (~> 12.12.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseMessaging (12.12.0):
|
||||
- FirebaseCore (~> 12.12.0)
|
||||
- FirebaseInstallations (~> 12.12.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Reachability (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- Flutter (1.0.0)
|
||||
- flutter_app_badge (2.0.0):
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Network (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Reachability
|
||||
- "GoogleUtilities/NSData+zlib (8.1.0)":
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/Reachability (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- home_widget (0.0.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- open_filex (0.0.2):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- PhoneNumberKit (3.7.11):
|
||||
- PhoneNumberKit/PhoneNumberKitCore (= 3.7.11)
|
||||
- PhoneNumberKit/UIKit (= 3.7.11)
|
||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||
- PhoneNumberKit/UIKit (3.7.11):
|
||||
- PhoneNumberKit/PhoneNumberKitCore
|
||||
- PromisesObjC (2.4.0)
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- screen_brightness_ios (2.1.3):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.7):
|
||||
- SDWebImage/Core (= 5.21.7)
|
||||
- SDWebImage/Core (5.21.7)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SwiftyGif (5.4.5)
|
||||
- syncfusion_flutter_pdfviewer (0.0.1):
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- workmanager_apple (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
|
||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- PhoneNumberKit (~> 3.7.6)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- Firebase
|
||||
- FirebaseCore
|
||||
- FirebaseCoreInternal
|
||||
- FirebaseInstallations
|
||||
- FirebaseMessaging
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
- nanopb
|
||||
- PhoneNumberKit
|
||||
- PromisesObjC
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
emoji_picker_flutter:
|
||||
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
|
||||
eraser:
|
||||
:path: ".symlinks/plugins/eraser/ios"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
firebase_core:
|
||||
:path: ".symlinks/plugins/firebase_core/ios"
|
||||
firebase_messaging:
|
||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_app_badge:
|
||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
home_widget:
|
||||
:path: ".symlinks/plugins/home_widget/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
open_filex:
|
||||
:path: ".symlinks/plugins/open_filex/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
screen_brightness_ios:
|
||||
:path: ".symlinks/plugins/screen_brightness_ios/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
syncfusion_flutter_pdfviewer:
|
||||
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
workmanager_apple:
|
||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
|
||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
|
||||
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
|
||||
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
|
||||
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
|
||||
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
|
||||
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
|
||||
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
PhoneNumberKit: ced55861269312a5e3bc2ef82a58d6255b1c976a
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
screen_brightness_ios: 212d950bb99c915eee971c884f4a6c87c92cd13d
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||
|
||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||
PODFILE CHECKSUM: 424a9b4c0fe81d8ebeaa9cb0dfedb60a68b19a0d
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -12,18 +12,13 @@
|
||||
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 */; };
|
||||
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -41,13 +36,6 @@
|
||||
remoteGlobalIDString = AA0101010000000011111111;
|
||||
remoteInfo = TimetableWidgetExtension;
|
||||
};
|
||||
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
|
||||
remoteInfo = NotificationServiceExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -59,7 +47,6 @@
|
||||
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;
|
||||
@@ -77,24 +64,17 @@
|
||||
/* 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>"; };
|
||||
@@ -113,12 +93,9 @@
|
||||
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>"; };
|
||||
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 */
|
||||
@@ -178,7 +155,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -190,14 +166,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -214,40 +182,18 @@
|
||||
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 = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -258,9 +204,6 @@
|
||||
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>";
|
||||
@@ -275,7 +218,6 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
345F4BD4143471FDA71626DE /* Pods */,
|
||||
731388A08E3B330B216381D0 /* Frameworks */,
|
||||
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -285,7 +227,6 @@
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
||||
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -345,18 +286,15 @@
|
||||
3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */,
|
||||
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
||||
AA0101090000000011111111 /* PBXTargetDependency */,
|
||||
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -381,23 +319,6 @@
|
||||
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 */
|
||||
@@ -430,9 +351,6 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -440,19 +358,11 @@
|
||||
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;
|
||||
@@ -515,6 +425,23 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -601,15 +528,6 @@
|
||||
);
|
||||
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 */
|
||||
@@ -618,12 +536,6 @@
|
||||
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 */;
|
||||
@@ -1025,29 +937,6 @@
|
||||
};
|
||||
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 */;
|
||||
@@ -1170,54 +1059,6 @@
|
||||
};
|
||||
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 */
|
||||
@@ -1261,31 +1102,7 @@
|
||||
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 "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "abseil-cpp-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||
"state" : {
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||
"version" : "1.2024072200.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
||||
"version" : "11.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "firebase-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||
"version" : "12.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
||||
"version" : "3.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleappmeasurement",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||
"version" : "12.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googledatatransport",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||
"state" : {
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||
"version" : "8.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "grpc-binary",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/grpc-binary.git",
|
||||
"state" : {
|
||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||
"version" : "1.69.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
|
||||
"version" : "5.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "leveldb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nanopb",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||
"version" : "2.30910.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "promises",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -5,24 +5,6 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -5,44 +5,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/abseil-cpp-binary.git",
|
||||
"state" : {
|
||||
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
|
||||
"version" : "1.2024072200.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
|
||||
"version" : "11.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
"revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c",
|
||||
"version" : "1.2022062300.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -50,17 +14,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||
"version" : "12.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "google-ads-on-device-conversion-ios-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
||||
"version" : "3.6.0"
|
||||
"revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be",
|
||||
"version" : "10.13.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||
"version" : "12.15.0"
|
||||
"revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b",
|
||||
"version" : "10.13.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -77,8 +32,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||
"state" : {
|
||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
||||
"version" : "10.1.0"
|
||||
"revision" : "aae45a320fd0d11811820335b1eabc8753902a40",
|
||||
"version" : "9.2.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +41,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
||||
"version" : "8.1.1"
|
||||
"revision" : "c38ce365d77b04a9a300c31061c5227589e5597b",
|
||||
"version" : "7.11.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,8 +50,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/grpc-binary.git",
|
||||
"state" : {
|
||||
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
|
||||
"version" : "1.69.1"
|
||||
"revision" : "f1b366129d1125be7db83247e003fc333104b569",
|
||||
"version" : "1.50.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -104,17 +59,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
|
||||
"version" : "5.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
"revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd",
|
||||
"version" : "3.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -122,8 +68,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
||||
"version" : "1.22.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -131,8 +77,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||
"version" : "2.30910.1"
|
||||
"revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692",
|
||||
"version" : "2.30909.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -140,35 +86,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||
"version" : "2.4.1"
|
||||
"revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e",
|
||||
"version" : "2.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"identity" : "swift-protobuf",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
"revision" : "ce20dc083ee485524b802669890291c0d8090170",
|
||||
"version" : "1.22.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,203 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
|
||||
// Kept in sync with lib/push/push_actions.dart (kTalkReplyActionId /
|
||||
// kTalkMarkReadActionId) and lib/push/push_renderer.dart (iosTalkCategory).
|
||||
private let talkCategoryId = "TALK_MESSAGE"
|
||||
private let replyActionId = "TALK_REPLY"
|
||||
private let markReadActionId = "TALK_MARK_READ"
|
||||
|
||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
||||
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"
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
registerTalkCategory()
|
||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||
// forwards these callbacks to the plugins (firebase_messaging,
|
||||
// flutter_local_notifications). We route Talk actions natively here — the
|
||||
// Flutter engine is not guaranteed to run for a background action — and
|
||||
// forward everything else to `super` so plugin behaviour is preserved.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
return result
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
|
||||
private func registerTalkCategory() {
|
||||
let reply = UNTextInputNotificationAction(
|
||||
identifier: replyActionId,
|
||||
title: "Antworten",
|
||||
options: [],
|
||||
textInputButtonTitle: "Senden",
|
||||
textInputPlaceholder: "Nachricht"
|
||||
)
|
||||
let markRead = UNNotificationAction(
|
||||
identifier: markReadActionId,
|
||||
title: "Als gelesen markieren",
|
||||
options: []
|
||||
)
|
||||
let category = UNNotificationCategory(
|
||||
identifier: talkCategoryId,
|
||||
actions: [reply, markRead],
|
||||
intentIdentifiers: [],
|
||||
options: []
|
||||
)
|
||||
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
override func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let actionId = response.actionIdentifier
|
||||
if actionId == replyActionId || actionId == markReadActionId {
|
||||
// Handled natively; deliberately NOT forwarded to super so the plugins
|
||||
// don't also process it (which would double-send the reply).
|
||||
handleTalkAction(response, completion: completionHandler)
|
||||
return
|
||||
}
|
||||
super.userNotificationCenter(
|
||||
center, didReceive: response, withCompletionHandler: completionHandler)
|
||||
}
|
||||
|
||||
// MARK: - Native Talk action handling
|
||||
|
||||
private func handleTalkAction(
|
||||
_ response: UNNotificationResponse,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard
|
||||
let token = chatToken(from: response),
|
||||
let credentials = loadCredentials()
|
||||
else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
if response.actionIdentifier == replyActionId,
|
||||
let text = (response as? UNTextInputNotificationResponse)?.userText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
group.enter()
|
||||
ocsPost(
|
||||
credentials: credentials,
|
||||
path: "apps/spreed/api/v1/chat/\(token)",
|
||||
form: ["message": text]
|
||||
) { group.leave() }
|
||||
}
|
||||
|
||||
// Both reply and mark-read clear the unread marker on the chat.
|
||||
group.enter()
|
||||
ocsPost(
|
||||
credentials: credentials,
|
||||
path: "apps/spreed/api/v1/chat/\(token)/read",
|
||||
form: nil
|
||||
) { group.leave() }
|
||||
|
||||
group.notify(queue: .main) { completion() }
|
||||
}
|
||||
|
||||
private func chatToken(from response: UNNotificationResponse) -> String? {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
if let token = userInfo["chatToken"] as? String, !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
let thread = response.notification.request.content.threadIdentifier
|
||||
if thread.hasPrefix("talk_") {
|
||||
let token = String(thread.dropFirst("talk_".count))
|
||||
return token.isEmpty ? nil : token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct Credentials {
|
||||
let baseUrl: String
|
||||
let authorization: String
|
||||
}
|
||||
|
||||
private func loadCredentials() -> Credentials? {
|
||||
guard
|
||||
let username = keychainString(usernameAccount),
|
||||
let appPassword = keychainString(appPasswordAccount),
|
||||
let baseUrl = keychainString(baseUrlAccount),
|
||||
let token = "\(username):\(appPassword)".data(using: .utf8)
|
||||
else { return nil }
|
||||
let authorization = "Basic \(token.base64EncodedString())"
|
||||
let trimmed = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
|
||||
return Credentials(baseUrl: trimmed, authorization: authorization)
|
||||
}
|
||||
|
||||
private func ocsPost(
|
||||
credentials: Credentials,
|
||||
path: String,
|
||||
form: [String: String]?,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard let url = URL(string: "\(credentials.baseUrl)/ocs/v2.php/\(path)") else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(credentials.authorization, forHTTPHeaderField: "Authorization")
|
||||
request.setValue("true", forHTTPHeaderField: "OCS-APIRequest")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let form = form {
|
||||
request.setValue(
|
||||
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = form
|
||||
.map { "\($0.key)=\(formEncode($0.value))" }
|
||||
.joined(separator: "&")
|
||||
.data(using: .utf8)
|
||||
}
|
||||
URLSession.shared.dataTask(with: request) { _, response, error in
|
||||
if let error = error {
|
||||
NSLog("[Talk action] \(path) failed: \(error.localizedDescription)")
|
||||
} else if let http = response as? HTTPURLResponse,
|
||||
!(200..<300).contains(http.statusCode) {
|
||||
NSLog("[Talk action] \(path) -> HTTP \(http.statusCode)")
|
||||
}
|
||||
completion()
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func formEncode(_ value: String) -> String {
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~")
|
||||
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
||||
}
|
||||
|
||||
// MARK: - Keychain (shared App Group)
|
||||
|
||||
private func keychainString(_ account: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: keychainAccessGroup,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,21 @@
|
||||
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Um Haltestellen in deiner Nähe im RMV-Fahrplan zu finden, wird dein aktueller Standort benötigt.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>muelleel.ddns.net</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -9,9 +9,5 @@
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.share</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<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 {
|
||||
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||
_ = SwiftReceiveSharingIntentPlugin.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 {
|
||||
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||
_ = SwiftReceiveSharingIntentPlugin.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
|
||||
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
private enum SharedMediaType: String, Codable {
|
||||
case image, video, text, file, url
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
class ApiRequest {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import 'login_request.dart';
|
||||
import 'login_response.dart';
|
||||
|
||||
class Login extends ConnectApi<LoginResponse> {
|
||||
final LoginRequest payload;
|
||||
|
||||
Login(this.payload) : super('auth/login');
|
||||
|
||||
@override
|
||||
bool get requiresAuth => false;
|
||||
|
||||
@override
|
||||
ConnectHttpMethod get method => ConnectHttpMethod.post;
|
||||
|
||||
@override
|
||||
Object? get body => payload.toJson();
|
||||
|
||||
@override
|
||||
LoginResponse assemble(String raw) =>
|
||||
LoginResponse.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'login_request.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class LoginRequest {
|
||||
final String username;
|
||||
final String password;
|
||||
final String tokenName;
|
||||
|
||||
LoginRequest({
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.tokenName,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'login_request.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
LoginRequest _$LoginRequestFromJson(Map<String, dynamic> json) => LoginRequest(
|
||||
username: json['username'] as String,
|
||||
password: json['password'] as String,
|
||||
tokenName: json['tokenName'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LoginRequestToJson(LoginRequest instance) =>
|
||||
<String, dynamic>{
|
||||
'username': instance.username,
|
||||
'password': instance.password,
|
||||
'tokenName': instance.tokenName,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Hand-rolled to be tolerant of the actual server payload: only [token] is
|
||||
/// load-bearing. `expiresAt` may be `null` (server-issued tokens without an
|
||||
/// explicit expiry); every other field shape is also tolerated so a stray
|
||||
/// rename on the backend does not break login for everyone.
|
||||
class LoginResponse {
|
||||
final String token;
|
||||
final String? tokenId;
|
||||
|
||||
/// `null` when the backend did not provide an expiry. In that case the
|
||||
/// token is treated as long-lived; callers should refresh on 401.
|
||||
final DateTime? expiresAt;
|
||||
final ConnectUserDto? user;
|
||||
|
||||
LoginResponse({
|
||||
required this.token,
|
||||
required this.tokenId,
|
||||
required this.expiresAt,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
final token = json['token'];
|
||||
if (token is! String || token.isEmpty) {
|
||||
throw const FormatException('login response missing "token" string');
|
||||
}
|
||||
final expiresRaw = json['expiresAt'];
|
||||
final expires = expiresRaw is String ? DateTime.tryParse(expiresRaw) : null;
|
||||
final userJson = json['user'];
|
||||
return LoginResponse(
|
||||
token: token,
|
||||
tokenId: json['tokenId']?.toString(),
|
||||
expiresAt: expires,
|
||||
user: userJson is Map<String, dynamic>
|
||||
? ConnectUserDto.fromJson(userJson)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConnectUserDto {
|
||||
final String? id;
|
||||
final String? username;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? userType;
|
||||
final String? className;
|
||||
|
||||
ConnectUserDto({
|
||||
this.id,
|
||||
this.username,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.userType,
|
||||
this.className,
|
||||
});
|
||||
|
||||
factory ConnectUserDto.fromJson(Map<String, dynamic> json) => ConnectUserDto(
|
||||
id: json['id']?.toString(),
|
||||
username: json['username']?.toString(),
|
||||
firstName: json['firstName']?.toString(),
|
||||
lastName: json['lastName']?.toString(),
|
||||
userType: json['userType']?.toString(),
|
||||
className: json['className']?.toString(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../api_request.dart';
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import '../errors/server_exception.dart';
|
||||
import 'connect_auth_store.dart';
|
||||
import 'connect_endpoint.dart';
|
||||
import 'errors/connect_exception.dart';
|
||||
import 'errors/rmv_rate_limited_exception.dart';
|
||||
import 'errors/rmv_upstream_exception.dart';
|
||||
|
||||
enum ConnectHttpMethod { get, post }
|
||||
|
||||
/// Mirrors the [MhslApi] pattern: each endpoint subclasses this, declares the
|
||||
/// subpath/query/body, and implements [assemble]. Handles bearer-token
|
||||
/// injection (via [ConnectAuthStore]), one transparent 401-retry after a
|
||||
/// fresh login, and turns the structured `RmvController.wrap` error strings
|
||||
/// into typed exceptions.
|
||||
abstract class ConnectApi<T> extends ApiRequest {
|
||||
final String subpath;
|
||||
|
||||
ConnectApi(this.subpath);
|
||||
|
||||
/// Override to `false` for endpoints that must NOT receive a bearer token
|
||||
/// (currently only login itself, to avoid an infinite refresh loop).
|
||||
bool get requiresAuth => true;
|
||||
|
||||
ConnectHttpMethod get method => ConnectHttpMethod.get;
|
||||
|
||||
Map<String, String>? get queryParameters => null;
|
||||
|
||||
/// Returns the body to send for POST requests. Should be JSON-encodable.
|
||||
Object? get body => null;
|
||||
|
||||
T assemble(String raw);
|
||||
|
||||
Future<T> run() async {
|
||||
final response = await _runOnce(forceTokenRefresh: false);
|
||||
if (response.statusCode == 401 && requiresAuth) {
|
||||
// Single transparent retry after a forced refresh, then bail.
|
||||
await ConnectAuthStore.instance.invalidate();
|
||||
final retry = await _runOnce(forceTokenRefresh: true);
|
||||
if (retry.statusCode == 401) {
|
||||
throw ConnectException.authFailed(
|
||||
technicalDetails:
|
||||
'connect $subpath HTTP 401 after token refresh: ${_safeBody(retry)}',
|
||||
);
|
||||
}
|
||||
return _handleResponse(retry);
|
||||
}
|
||||
return _handleResponse(response);
|
||||
}
|
||||
|
||||
Future<http.Response> _runOnce({required bool forceTokenRefresh}) async {
|
||||
final uri = ConnectEndpoint.resolve(subpath).replace(
|
||||
queryParameters: _normaliseQuery(queryParameters),
|
||||
);
|
||||
|
||||
final headers = <String, String>{
|
||||
if (method == ConnectHttpMethod.post) 'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
if (requiresAuth) {
|
||||
final token = await ConnectAuthStore.instance.getToken(
|
||||
forceRefresh: forceTokenRefresh,
|
||||
);
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case ConnectHttpMethod.get:
|
||||
return await http.get(uri, headers: headers);
|
||||
case ConnectHttpMethod.post:
|
||||
final payload = body;
|
||||
return await http.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: payload == null ? null : jsonEncode(payload),
|
||||
);
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(
|
||||
technicalDetails: 'connect $subpath: ${e.message}',
|
||||
);
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(
|
||||
technicalDetails: 'connect $subpath: $e',
|
||||
);
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(
|
||||
technicalDetails: 'connect $subpath: ${e.message}',
|
||||
);
|
||||
} on HandshakeException catch (e) {
|
||||
throw NetworkException(
|
||||
technicalDetails: 'connect $subpath TLS: ${e.message}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
T _handleResponse(http.Response response) {
|
||||
final status = response.statusCode;
|
||||
final bodyText = _safeBody(response);
|
||||
|
||||
if (status == 503) {
|
||||
final retryAfter = _parseRetryAfter(bodyText);
|
||||
throw RmvRateLimitedException(
|
||||
retryAfter: retryAfter,
|
||||
technicalDetails: 'connect $subpath HTTP 503: $bodyText',
|
||||
);
|
||||
}
|
||||
if (status == 502) {
|
||||
final code = _parseUpstreamErrorCode(bodyText);
|
||||
throw RmvUpstreamException(
|
||||
errorCode: code,
|
||||
technicalDetails: 'connect $subpath HTTP 502: $bodyText',
|
||||
);
|
||||
}
|
||||
if (status > 299) {
|
||||
throw ServerException(
|
||||
statusCode: status,
|
||||
technicalDetails: 'connect $subpath HTTP $status: $bodyText',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return assemble(bodyText);
|
||||
} catch (e, st) {
|
||||
final preview = bodyText.length > 1024
|
||||
? '${bodyText.substring(0, 1024)}…'
|
||||
: bodyText;
|
||||
log(
|
||||
'connect $subpath assemble failed: $e\nbody: $preview',
|
||||
stackTrace: st,
|
||||
);
|
||||
throw ParseException(
|
||||
technicalDetails: 'connect $subpath assemble: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _safeBody(http.Response response) {
|
||||
try {
|
||||
return utf8.decode(response.bodyBytes);
|
||||
} catch (_) {
|
||||
return response.body;
|
||||
}
|
||||
}
|
||||
|
||||
/// Body format from `RmvController.wrap`: `upstream_rate_limited|retryAfter=60`.
|
||||
Duration _parseRetryAfter(String body) {
|
||||
final match = RegExp(r'retryAfter=(\d+)').firstMatch(body);
|
||||
final seconds = match == null ? 60 : int.tryParse(match.group(1)!) ?? 60;
|
||||
return Duration(seconds: seconds);
|
||||
}
|
||||
|
||||
/// Body format: `upstream_error|H390` — the segment after the pipe is the
|
||||
/// RMV/HaFAS error code.
|
||||
String? _parseUpstreamErrorCode(String body) {
|
||||
final idx = body.indexOf('|');
|
||||
if (idx < 0 || idx >= body.length - 1) return null;
|
||||
return body.substring(idx + 1).trim();
|
||||
}
|
||||
|
||||
Map<String, String>? _normaliseQuery(Map<String, String>? raw) {
|
||||
if (raw == null) return null;
|
||||
final cleaned = <String, String>{};
|
||||
raw.forEach((key, value) {
|
||||
if (value.isNotEmpty) cleaned[key] = value;
|
||||
});
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../model/account_data.dart';
|
||||
import 'auth/login/login.dart';
|
||||
import 'auth/login/login_request.dart';
|
||||
import 'auth/login/login_response.dart';
|
||||
import 'errors/connect_exception.dart';
|
||||
|
||||
/// Holds the Bearer token issued by `POST /auth/login` so that subsequent
|
||||
/// RMV calls can attach it without prompting the user. Uses the LDAP
|
||||
/// credentials already kept in [AccountData], so this is transparent to the
|
||||
/// user — no extra login UI.
|
||||
class ConnectAuthStore {
|
||||
static const _tokenKey = 'connect_bearer_token';
|
||||
static const _expiresAtKey = 'connect_token_expires_at';
|
||||
static const _tokenName = 'MarianumMobile App';
|
||||
static const _expiryGuard = Duration(minutes: 1);
|
||||
|
||||
static final ConnectAuthStore _instance = ConnectAuthStore._();
|
||||
factory ConnectAuthStore() => _instance;
|
||||
static ConnectAuthStore get instance => _instance;
|
||||
ConnectAuthStore._();
|
||||
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
|
||||
String? _token;
|
||||
DateTime? _expiresAt;
|
||||
bool _hydrated = false;
|
||||
Future<String>? _inflightLogin;
|
||||
|
||||
Future<void> _hydrate() async {
|
||||
if (_hydrated) return;
|
||||
_token = await _storage.read(key: _tokenKey);
|
||||
final rawExp = await _storage.read(key: _expiresAtKey);
|
||||
_expiresAt = rawExp == null ? null : DateTime.tryParse(rawExp);
|
||||
_hydrated = true;
|
||||
}
|
||||
|
||||
bool _isUsable() {
|
||||
if (_token == null || _token!.isEmpty) return false;
|
||||
final exp = _expiresAt;
|
||||
if (exp == null) return true;
|
||||
return DateTime.now().add(_expiryGuard).isBefore(exp);
|
||||
}
|
||||
|
||||
/// Returns a usable bearer token, logging in if necessary. Concurrent
|
||||
/// callers share the same in-flight login future so a single 401 doesn't
|
||||
/// trigger N parallel logins.
|
||||
Future<String> getToken({bool forceRefresh = false}) async {
|
||||
await _hydrate();
|
||||
if (!forceRefresh && _isUsable()) return _token!;
|
||||
return _inflightLogin ??= _login().whenComplete(() {
|
||||
_inflightLogin = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> _login() async {
|
||||
if (!AccountData().isPopulated()) {
|
||||
throw ConnectException.notAuthenticated();
|
||||
}
|
||||
final username = AccountData().getUsername();
|
||||
final password = AccountData().getPassword();
|
||||
final LoginResponse response;
|
||||
try {
|
||||
response = await Login(
|
||||
LoginRequest(
|
||||
username: username,
|
||||
password: password,
|
||||
tokenName: _tokenName,
|
||||
),
|
||||
).run();
|
||||
} on ConnectException {
|
||||
rethrow;
|
||||
} catch (e, st) {
|
||||
log('connect login threw: $e', stackTrace: st);
|
||||
throw ConnectException(
|
||||
userMessage:
|
||||
'Anmeldung am Connect-Server fehlgeschlagen. Bitte später erneut versuchen.',
|
||||
technicalDetails: 'connect login failed: $e',
|
||||
);
|
||||
}
|
||||
_token = response.token;
|
||||
_expiresAt = response.expiresAt;
|
||||
await _storage.write(key: _tokenKey, value: response.token);
|
||||
if (response.expiresAt != null) {
|
||||
await _storage.write(
|
||||
key: _expiresAtKey,
|
||||
value: response.expiresAt!.toIso8601String(),
|
||||
);
|
||||
} else {
|
||||
await _storage.delete(key: _expiresAtKey);
|
||||
}
|
||||
return response.token;
|
||||
}
|
||||
|
||||
Future<void> invalidate() async {
|
||||
_token = null;
|
||||
_expiresAt = null;
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _expiresAtKey);
|
||||
}
|
||||
|
||||
/// Same as [invalidate] — separate method to make logout call-sites read
|
||||
/// clearly.
|
||||
Future<void> clear() => invalidate();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// Base URL for the MarianumConnect backend. Hardcoded against the test
|
||||
/// instance for now; once the production URL is finalised this should move
|
||||
/// into `EndpointData` alongside webuntis/nextcloud.
|
||||
class ConnectEndpoint {
|
||||
ConnectEndpoint._();
|
||||
|
||||
static const String _baseUrl = 'http://muelleel.ddns.net:8080';
|
||||
static const String _apiPrefix = '/api/mobile/v1';
|
||||
|
||||
static Uri resolve(String subpath) =>
|
||||
Uri.parse('$_baseUrl$_apiPrefix/${subpath.startsWith('/') ? subpath.substring(1) : subpath}');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import '../../errors/app_exception.dart';
|
||||
|
||||
class ConnectException extends AppException {
|
||||
const ConnectException({
|
||||
super.userMessage =
|
||||
'Verbindung zum Marianum-Connect-Server fehlgeschlagen.',
|
||||
super.technicalDetails,
|
||||
super.allowRetry,
|
||||
});
|
||||
|
||||
factory ConnectException.authFailed({String? technicalDetails}) =>
|
||||
ConnectException(
|
||||
userMessage:
|
||||
'Anmeldung am Connect-Server fehlgeschlagen. Bitte prüfe deine Anmeldedaten.',
|
||||
technicalDetails: technicalDetails,
|
||||
allowRetry: false,
|
||||
);
|
||||
|
||||
factory ConnectException.notAuthenticated() => const ConnectException(
|
||||
userMessage:
|
||||
'Für diese Funktion ist eine Anmeldung am Connect-Server nötig.',
|
||||
technicalDetails: 'AccountData missing while trying to log in to connect',
|
||||
allowRetry: false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import '../../errors/app_exception.dart';
|
||||
|
||||
class RmvRateLimitedException extends AppException {
|
||||
final Duration retryAfter;
|
||||
|
||||
RmvRateLimitedException({required this.retryAfter, super.technicalDetails})
|
||||
: super(
|
||||
userMessage:
|
||||
'Die Fahrplanauskunft ist gerade überlastet. Bitte in ${retryAfter.inSeconds} Sekunden erneut versuchen.',
|
||||
allowRetry: true,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../../errors/app_exception.dart';
|
||||
|
||||
class RmvUpstreamException extends AppException {
|
||||
final String? errorCode;
|
||||
|
||||
RmvUpstreamException({required this.errorCode, super.technicalDetails})
|
||||
: super(userMessage: _mapMessage(errorCode), allowRetry: true);
|
||||
|
||||
static String _mapMessage(String? code) {
|
||||
switch (code) {
|
||||
case 'H390':
|
||||
return 'Keine Verbindung gefunden.';
|
||||
case 'H891':
|
||||
return 'Eine der angegebenen Stationen ist ungültig.';
|
||||
case 'H895':
|
||||
return 'Start- und Zielhaltestelle sind identisch.';
|
||||
case 'H900':
|
||||
case 'H892':
|
||||
return 'Die Fahrplanauskunft ist gerade nicht verfügbar.';
|
||||
case 'H910':
|
||||
return 'Die angegebene Zeit liegt außerhalb des Fahrplans.';
|
||||
case null:
|
||||
return 'Die Fahrplanauskunft konnte keine Antwort liefern.';
|
||||
default:
|
||||
return 'Die Fahrplanauskunft hat einen Fehler gemeldet ($code).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/// ISO-8601 duration (`PT1H30M5S`) ↔ Dart `Duration`. Backend serialises
|
||||
/// `java.time.Duration` in this format; Dart has no builtin parser.
|
||||
class IsoDuration {
|
||||
IsoDuration._();
|
||||
|
||||
static final RegExp _pattern = RegExp(
|
||||
r'^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$',
|
||||
);
|
||||
|
||||
static Duration? fromJson(String? iso) {
|
||||
if (iso == null || iso.isEmpty) return null;
|
||||
final match = _pattern.firstMatch(iso);
|
||||
if (match == null) return null;
|
||||
final hours = int.parse(match.group(1) ?? '0');
|
||||
final minutes = int.parse(match.group(2) ?? '0');
|
||||
final secondsRaw = match.group(3) ?? '0';
|
||||
final secondsValue = double.parse(secondsRaw);
|
||||
return Duration(
|
||||
hours: hours,
|
||||
minutes: minutes,
|
||||
milliseconds: (secondsValue * 1000).round(),
|
||||
);
|
||||
}
|
||||
|
||||
static String? toJson(Duration? d) {
|
||||
if (d == null) return null;
|
||||
final hours = d.inHours;
|
||||
final minutes = d.inMinutes.remainder(60);
|
||||
final seconds = d.inSeconds.remainder(60);
|
||||
final buf = StringBuffer('PT');
|
||||
if (hours > 0) buf.write('${hours}H');
|
||||
if (minutes > 0) buf.write('${minutes}M');
|
||||
if (seconds > 0 || (hours == 0 && minutes == 0)) buf.write('${seconds}S');
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// Formats a [DateTime] as `2026-05-19T14:30:00` for Java's
|
||||
/// `LocalDateTime` parser (no timezone, no millis).
|
||||
String formatLocalDateTime(DateTime dt) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${dt.year}-${two(dt.month)}-${two(dt.day)}T'
|
||||
'${two(dt.hour)}:${two(dt.minute)}:${two(dt.second)}';
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] as `2026-05-19` for Java's `LocalDate` parser.
|
||||
String formatLocalDate(DateTime dt) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${dt.year}-${two(dt.month)}-${two(dt.day)}';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
import '_query_format.dart';
|
||||
|
||||
class GetArrivals extends ConnectApi<List<Arrival>> {
|
||||
final String stopId;
|
||||
final DateTime? when;
|
||||
final int durationMinutes;
|
||||
final int maxJourneys;
|
||||
|
||||
GetArrivals({
|
||||
required this.stopId,
|
||||
this.when,
|
||||
this.durationMinutes = 60,
|
||||
this.maxJourneys = -1,
|
||||
}) : super('rmv/arrivals');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {
|
||||
'stopId': stopId,
|
||||
if (when != null) 'when': formatLocalDateTime(when!),
|
||||
'duration': durationMinutes.toString(),
|
||||
'max': maxJourneys.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
List<Arrival> assemble(String raw) => (jsonDecode(raw) as List)
|
||||
.map((e) => Arrival.fromJson(e as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
import '_query_format.dart';
|
||||
|
||||
class GetDepartures extends ConnectApi<List<Departure>> {
|
||||
final String stopId;
|
||||
final DateTime? when;
|
||||
final int durationMinutes;
|
||||
final int maxJourneys;
|
||||
|
||||
GetDepartures({
|
||||
required this.stopId,
|
||||
this.when,
|
||||
this.durationMinutes = 60,
|
||||
this.maxJourneys = -1,
|
||||
}) : super('rmv/departures');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {
|
||||
'stopId': stopId,
|
||||
if (when != null) 'when': formatLocalDateTime(when!),
|
||||
'duration': durationMinutes.toString(),
|
||||
'max': maxJourneys.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
List<Departure> assemble(String raw) => (jsonDecode(raw) as List)
|
||||
.map((e) => Departure.fromJson(e as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
import '_query_format.dart';
|
||||
|
||||
class GetDisruptions extends ConnectApi<List<HimMessage>> {
|
||||
final DateTime? when;
|
||||
|
||||
GetDisruptions({this.when}) : super('rmv/disruptions');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters =>
|
||||
when == null ? null : {'when': formatLocalDateTime(when!)};
|
||||
|
||||
@override
|
||||
List<HimMessage> assemble(String raw) => (jsonDecode(raw) as List)
|
||||
.map((e) => HimMessage.fromJson(e as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
import '_query_format.dart';
|
||||
|
||||
class GetJourneyDetail extends ConnectApi<JourneyDetail> {
|
||||
final String journeyRef;
|
||||
final DateTime? date;
|
||||
|
||||
GetJourneyDetail({required this.journeyRef, this.date})
|
||||
: super('rmv/journey');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {
|
||||
'ref': journeyRef,
|
||||
if (date != null) 'date': formatLocalDate(date!),
|
||||
};
|
||||
|
||||
@override
|
||||
JourneyDetail assemble(String raw) =>
|
||||
JourneyDetail.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
|
||||
class MoreTrips extends ConnectApi<TripSearchResult> {
|
||||
final String ctx;
|
||||
|
||||
MoreTrips({required this.ctx}) : super('rmv/trips/more');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {'ctx': ctx};
|
||||
|
||||
@override
|
||||
TripSearchResult assemble(String raw) =>
|
||||
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
|
||||
class NearbyStops extends ConnectApi<List<StopLocation>> {
|
||||
final double lat;
|
||||
final double lon;
|
||||
final int radiusMeters;
|
||||
final int max;
|
||||
|
||||
NearbyStops({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
this.radiusMeters = 1000,
|
||||
this.max = 20,
|
||||
}) : super('rmv/stops/nearby');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {
|
||||
'lat': lat.toString(),
|
||||
'lon': lon.toString(),
|
||||
'radius': radiusMeters.toString(),
|
||||
'max': max.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
|
||||
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
|
||||
class SearchStops extends ConnectApi<List<StopLocation>> {
|
||||
final String query;
|
||||
final int max;
|
||||
|
||||
SearchStops({required this.query, this.max = 10}) : super('rmv/stops');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {
|
||||
'q': query,
|
||||
'max': max.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
|
||||
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../connect_api.dart';
|
||||
import '../rmv_models.dart';
|
||||
import '_query_format.dart';
|
||||
|
||||
class SearchTrips extends ConnectApi<TripSearchResult> {
|
||||
final String fromStopId;
|
||||
final String toStopId;
|
||||
final DateTime? when;
|
||||
final bool searchByArrival;
|
||||
|
||||
SearchTrips({
|
||||
required this.fromStopId,
|
||||
required this.toStopId,
|
||||
this.when,
|
||||
this.searchByArrival = false,
|
||||
}) : super('rmv/trips');
|
||||
|
||||
@override
|
||||
Map<String, String>? get queryParameters => {
|
||||
'from': fromStopId,
|
||||
'to': toStopId,
|
||||
if (when != null) 'when': formatLocalDateTime(when!),
|
||||
'searchByArrival': searchByArrival.toString(),
|
||||
};
|
||||
|
||||
@override
|
||||
TripSearchResult assemble(String raw) =>
|
||||
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'iso_duration.dart';
|
||||
|
||||
part 'rmv_models.freezed.dart';
|
||||
part 'rmv_models.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class Product with _$Product {
|
||||
const factory Product({
|
||||
String? name,
|
||||
String? line,
|
||||
String? displayNumber,
|
||||
String? category,
|
||||
String? categoryCode,
|
||||
String? operator,
|
||||
}) = _Product;
|
||||
|
||||
factory Product.fromJson(Map<String, Object?> json) =>
|
||||
_$ProductFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class StopLocation with _$StopLocation {
|
||||
const factory StopLocation({
|
||||
required String id,
|
||||
String? extId,
|
||||
required String name,
|
||||
String? description,
|
||||
double? lat,
|
||||
double? lon,
|
||||
int? products,
|
||||
int? distanceMeters,
|
||||
}) = _StopLocation;
|
||||
|
||||
factory StopLocation.fromJson(Map<String, Object?> json) =>
|
||||
_$StopLocationFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class Departure with _$Departure {
|
||||
const factory Departure({
|
||||
required String stopId,
|
||||
String? stopExtId,
|
||||
required String stopName,
|
||||
required String name,
|
||||
required String direction,
|
||||
String? directionFlag,
|
||||
required DateTime scheduledTime,
|
||||
DateTime? realTime,
|
||||
int? delayMinutes,
|
||||
String? track,
|
||||
String? realTrack,
|
||||
@Default(false) bool cancelled,
|
||||
@Default(true) bool reachable,
|
||||
Product? product,
|
||||
String? journeyRef,
|
||||
}) = _Departure;
|
||||
|
||||
factory Departure.fromJson(Map<String, Object?> json) =>
|
||||
_$DepartureFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class Arrival with _$Arrival {
|
||||
const factory Arrival({
|
||||
required String stopId,
|
||||
String? stopExtId,
|
||||
required String stopName,
|
||||
required String name,
|
||||
required String origin,
|
||||
required DateTime scheduledTime,
|
||||
DateTime? realTime,
|
||||
int? delayMinutes,
|
||||
String? track,
|
||||
String? realTrack,
|
||||
@Default(false) bool cancelled,
|
||||
Product? product,
|
||||
String? journeyRef,
|
||||
}) = _Arrival;
|
||||
|
||||
factory Arrival.fromJson(Map<String, Object?> json) =>
|
||||
_$ArrivalFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TripEndpoint with _$TripEndpoint {
|
||||
const factory TripEndpoint({
|
||||
required String stopId,
|
||||
String? stopExtId,
|
||||
required String name,
|
||||
double? lat,
|
||||
double? lon,
|
||||
required DateTime scheduledTime,
|
||||
DateTime? realTime,
|
||||
int? delayMinutes,
|
||||
String? track,
|
||||
String? realTrack,
|
||||
String? type,
|
||||
}) = _TripEndpoint;
|
||||
|
||||
factory TripEndpoint.fromJson(Map<String, Object?> json) =>
|
||||
_$TripEndpointFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class JourneyStop with _$JourneyStop {
|
||||
const factory JourneyStop({
|
||||
required String id,
|
||||
String? extId,
|
||||
required String name,
|
||||
double? lat,
|
||||
double? lon,
|
||||
int? routeIdx,
|
||||
DateTime? scheduledArrival,
|
||||
DateTime? scheduledDeparture,
|
||||
DateTime? realArrival,
|
||||
DateTime? realDeparture,
|
||||
String? arrTrack,
|
||||
String? depTrack,
|
||||
String? realArrTrack,
|
||||
String? realDepTrack,
|
||||
@Default(false) bool cancelled,
|
||||
@Default(false) bool cancelledArrival,
|
||||
@Default(false) bool cancelledDeparture,
|
||||
}) = _JourneyStop;
|
||||
|
||||
factory JourneyStop.fromJson(Map<String, Object?> json) =>
|
||||
_$JourneyStopFromJson(json);
|
||||
}
|
||||
|
||||
enum LegType {
|
||||
@JsonValue('JOURNEY')
|
||||
journey,
|
||||
@JsonValue('WALK')
|
||||
walk,
|
||||
@JsonValue('TRANSFER')
|
||||
transfer,
|
||||
@JsonValue('BIKE')
|
||||
bike,
|
||||
@JsonValue('CAR')
|
||||
car,
|
||||
@JsonValue('PARK_RIDE')
|
||||
parkRide,
|
||||
@JsonValue('TAXI')
|
||||
taxi,
|
||||
@JsonValue('CHECK_IN')
|
||||
checkIn,
|
||||
@JsonValue('CHECK_OUT')
|
||||
checkOut,
|
||||
@JsonValue('DUMMY')
|
||||
dummy,
|
||||
@JsonValue('UNKNOWN')
|
||||
unknown,
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class Leg with _$Leg {
|
||||
const factory Leg({
|
||||
required String id,
|
||||
required int idx,
|
||||
@Default(LegType.unknown) LegType type,
|
||||
String? name,
|
||||
String? category,
|
||||
String? number,
|
||||
String? direction,
|
||||
required TripEndpoint origin,
|
||||
required TripEndpoint destination,
|
||||
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
|
||||
Duration? duration,
|
||||
@Default(false) bool cancelled,
|
||||
@Default(false) bool partCancelled,
|
||||
@Default(true) bool reachable,
|
||||
Product? product,
|
||||
String? journeyRef,
|
||||
@Default(<JourneyStop>[]) List<JourneyStop> stops,
|
||||
}) = _Leg;
|
||||
|
||||
factory Leg.fromJson(Map<String, Object?> json) => _$LegFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class Trip with _$Trip {
|
||||
const factory Trip({
|
||||
String? tripId,
|
||||
String? ctxRecon,
|
||||
String? checksum,
|
||||
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
|
||||
Duration? duration,
|
||||
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
|
||||
Duration? realDuration,
|
||||
int? transferCount,
|
||||
@Default(<Leg>[]) List<Leg> legs,
|
||||
}) = _Trip;
|
||||
|
||||
factory Trip.fromJson(Map<String, Object?> json) => _$TripFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class TripSearchResult with _$TripSearchResult {
|
||||
const factory TripSearchResult({
|
||||
@Default(<Trip>[]) List<Trip> trips,
|
||||
String? scrollContextLater,
|
||||
String? scrollContextEarlier,
|
||||
}) = _TripSearchResult;
|
||||
|
||||
factory TripSearchResult.fromJson(Map<String, Object?> json) =>
|
||||
_$TripSearchResultFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class JourneyDetail with _$JourneyDetail {
|
||||
const factory JourneyDetail({
|
||||
String? journeyId,
|
||||
Product? product,
|
||||
String? direction,
|
||||
@Default(<JourneyStop>[]) List<JourneyStop> stops,
|
||||
}) = _JourneyDetail;
|
||||
|
||||
factory JourneyDetail.fromJson(Map<String, Object?> json) =>
|
||||
_$JourneyDetailFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class HimMessage with _$HimMessage {
|
||||
const factory HimMessage({
|
||||
required String id,
|
||||
String? externalId,
|
||||
String? head,
|
||||
String? lead,
|
||||
String? text,
|
||||
String? category,
|
||||
String? company,
|
||||
int? priority,
|
||||
int? products,
|
||||
DateTime? startValidity,
|
||||
DateTime? endValidity,
|
||||
DateTime? modified,
|
||||
}) = _HimMessage;
|
||||
|
||||
factory HimMessage.fromJson(Map<String, Object?> json) =>
|
||||
_$HimMessageFromJson(json);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'rmv_models.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_Product _$ProductFromJson(Map<String, dynamic> json) => _Product(
|
||||
name: json['name'] as String?,
|
||||
line: json['line'] as String?,
|
||||
displayNumber: json['displayNumber'] as String?,
|
||||
category: json['category'] as String?,
|
||||
categoryCode: json['categoryCode'] as String?,
|
||||
operator: json['operator'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProductToJson(_Product instance) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'line': instance.line,
|
||||
'displayNumber': instance.displayNumber,
|
||||
'category': instance.category,
|
||||
'categoryCode': instance.categoryCode,
|
||||
'operator': instance.operator,
|
||||
};
|
||||
|
||||
_StopLocation _$StopLocationFromJson(Map<String, dynamic> json) =>
|
||||
_StopLocation(
|
||||
id: json['id'] as String,
|
||||
extId: json['extId'] as String?,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
lat: (json['lat'] as num?)?.toDouble(),
|
||||
lon: (json['lon'] as num?)?.toDouble(),
|
||||
products: (json['products'] as num?)?.toInt(),
|
||||
distanceMeters: (json['distanceMeters'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$StopLocationToJson(_StopLocation instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'extId': instance.extId,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'lat': instance.lat,
|
||||
'lon': instance.lon,
|
||||
'products': instance.products,
|
||||
'distanceMeters': instance.distanceMeters,
|
||||
};
|
||||
|
||||
_Departure _$DepartureFromJson(Map<String, dynamic> json) => _Departure(
|
||||
stopId: json['stopId'] as String,
|
||||
stopExtId: json['stopExtId'] as String?,
|
||||
stopName: json['stopName'] as String,
|
||||
name: json['name'] as String,
|
||||
direction: json['direction'] as String,
|
||||
directionFlag: json['directionFlag'] as String?,
|
||||
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
|
||||
realTime: json['realTime'] == null
|
||||
? null
|
||||
: DateTime.parse(json['realTime'] as String),
|
||||
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
|
||||
track: json['track'] as String?,
|
||||
realTrack: json['realTrack'] as String?,
|
||||
cancelled: json['cancelled'] as bool? ?? false,
|
||||
reachable: json['reachable'] as bool? ?? true,
|
||||
product: json['product'] == null
|
||||
? null
|
||||
: Product.fromJson(json['product'] as Map<String, dynamic>),
|
||||
journeyRef: json['journeyRef'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DepartureToJson(_Departure instance) =>
|
||||
<String, dynamic>{
|
||||
'stopId': instance.stopId,
|
||||
'stopExtId': instance.stopExtId,
|
||||
'stopName': instance.stopName,
|
||||
'name': instance.name,
|
||||
'direction': instance.direction,
|
||||
'directionFlag': instance.directionFlag,
|
||||
'scheduledTime': instance.scheduledTime.toIso8601String(),
|
||||
'realTime': instance.realTime?.toIso8601String(),
|
||||
'delayMinutes': instance.delayMinutes,
|
||||
'track': instance.track,
|
||||
'realTrack': instance.realTrack,
|
||||
'cancelled': instance.cancelled,
|
||||
'reachable': instance.reachable,
|
||||
'product': instance.product,
|
||||
'journeyRef': instance.journeyRef,
|
||||
};
|
||||
|
||||
_Arrival _$ArrivalFromJson(Map<String, dynamic> json) => _Arrival(
|
||||
stopId: json['stopId'] as String,
|
||||
stopExtId: json['stopExtId'] as String?,
|
||||
stopName: json['stopName'] as String,
|
||||
name: json['name'] as String,
|
||||
origin: json['origin'] as String,
|
||||
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
|
||||
realTime: json['realTime'] == null
|
||||
? null
|
||||
: DateTime.parse(json['realTime'] as String),
|
||||
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
|
||||
track: json['track'] as String?,
|
||||
realTrack: json['realTrack'] as String?,
|
||||
cancelled: json['cancelled'] as bool? ?? false,
|
||||
product: json['product'] == null
|
||||
? null
|
||||
: Product.fromJson(json['product'] as Map<String, dynamic>),
|
||||
journeyRef: json['journeyRef'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ArrivalToJson(_Arrival instance) => <String, dynamic>{
|
||||
'stopId': instance.stopId,
|
||||
'stopExtId': instance.stopExtId,
|
||||
'stopName': instance.stopName,
|
||||
'name': instance.name,
|
||||
'origin': instance.origin,
|
||||
'scheduledTime': instance.scheduledTime.toIso8601String(),
|
||||
'realTime': instance.realTime?.toIso8601String(),
|
||||
'delayMinutes': instance.delayMinutes,
|
||||
'track': instance.track,
|
||||
'realTrack': instance.realTrack,
|
||||
'cancelled': instance.cancelled,
|
||||
'product': instance.product,
|
||||
'journeyRef': instance.journeyRef,
|
||||
};
|
||||
|
||||
_TripEndpoint _$TripEndpointFromJson(Map<String, dynamic> json) =>
|
||||
_TripEndpoint(
|
||||
stopId: json['stopId'] as String,
|
||||
stopExtId: json['stopExtId'] as String?,
|
||||
name: json['name'] as String,
|
||||
lat: (json['lat'] as num?)?.toDouble(),
|
||||
lon: (json['lon'] as num?)?.toDouble(),
|
||||
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
|
||||
realTime: json['realTime'] == null
|
||||
? null
|
||||
: DateTime.parse(json['realTime'] as String),
|
||||
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
|
||||
track: json['track'] as String?,
|
||||
realTrack: json['realTrack'] as String?,
|
||||
type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TripEndpointToJson(_TripEndpoint instance) =>
|
||||
<String, dynamic>{
|
||||
'stopId': instance.stopId,
|
||||
'stopExtId': instance.stopExtId,
|
||||
'name': instance.name,
|
||||
'lat': instance.lat,
|
||||
'lon': instance.lon,
|
||||
'scheduledTime': instance.scheduledTime.toIso8601String(),
|
||||
'realTime': instance.realTime?.toIso8601String(),
|
||||
'delayMinutes': instance.delayMinutes,
|
||||
'track': instance.track,
|
||||
'realTrack': instance.realTrack,
|
||||
'type': instance.type,
|
||||
};
|
||||
|
||||
_JourneyStop _$JourneyStopFromJson(Map<String, dynamic> json) => _JourneyStop(
|
||||
id: json['id'] as String,
|
||||
extId: json['extId'] as String?,
|
||||
name: json['name'] as String,
|
||||
lat: (json['lat'] as num?)?.toDouble(),
|
||||
lon: (json['lon'] as num?)?.toDouble(),
|
||||
routeIdx: (json['routeIdx'] as num?)?.toInt(),
|
||||
scheduledArrival: json['scheduledArrival'] == null
|
||||
? null
|
||||
: DateTime.parse(json['scheduledArrival'] as String),
|
||||
scheduledDeparture: json['scheduledDeparture'] == null
|
||||
? null
|
||||
: DateTime.parse(json['scheduledDeparture'] as String),
|
||||
realArrival: json['realArrival'] == null
|
||||
? null
|
||||
: DateTime.parse(json['realArrival'] as String),
|
||||
realDeparture: json['realDeparture'] == null
|
||||
? null
|
||||
: DateTime.parse(json['realDeparture'] as String),
|
||||
arrTrack: json['arrTrack'] as String?,
|
||||
depTrack: json['depTrack'] as String?,
|
||||
realArrTrack: json['realArrTrack'] as String?,
|
||||
realDepTrack: json['realDepTrack'] as String?,
|
||||
cancelled: json['cancelled'] as bool? ?? false,
|
||||
cancelledArrival: json['cancelledArrival'] as bool? ?? false,
|
||||
cancelledDeparture: json['cancelledDeparture'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$JourneyStopToJson(_JourneyStop instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'extId': instance.extId,
|
||||
'name': instance.name,
|
||||
'lat': instance.lat,
|
||||
'lon': instance.lon,
|
||||
'routeIdx': instance.routeIdx,
|
||||
'scheduledArrival': instance.scheduledArrival?.toIso8601String(),
|
||||
'scheduledDeparture': instance.scheduledDeparture?.toIso8601String(),
|
||||
'realArrival': instance.realArrival?.toIso8601String(),
|
||||
'realDeparture': instance.realDeparture?.toIso8601String(),
|
||||
'arrTrack': instance.arrTrack,
|
||||
'depTrack': instance.depTrack,
|
||||
'realArrTrack': instance.realArrTrack,
|
||||
'realDepTrack': instance.realDepTrack,
|
||||
'cancelled': instance.cancelled,
|
||||
'cancelledArrival': instance.cancelledArrival,
|
||||
'cancelledDeparture': instance.cancelledDeparture,
|
||||
};
|
||||
|
||||
_Leg _$LegFromJson(Map<String, dynamic> json) => _Leg(
|
||||
id: json['id'] as String,
|
||||
idx: (json['idx'] as num).toInt(),
|
||||
type: $enumDecodeNullable(_$LegTypeEnumMap, json['type']) ?? LegType.unknown,
|
||||
name: json['name'] as String?,
|
||||
category: json['category'] as String?,
|
||||
number: json['number'] as String?,
|
||||
direction: json['direction'] as String?,
|
||||
origin: TripEndpoint.fromJson(json['origin'] as Map<String, dynamic>),
|
||||
destination: TripEndpoint.fromJson(
|
||||
json['destination'] as Map<String, dynamic>,
|
||||
),
|
||||
duration: IsoDuration.fromJson(json['duration'] as String?),
|
||||
cancelled: json['cancelled'] as bool? ?? false,
|
||||
partCancelled: json['partCancelled'] as bool? ?? false,
|
||||
reachable: json['reachable'] as bool? ?? true,
|
||||
product: json['product'] == null
|
||||
? null
|
||||
: Product.fromJson(json['product'] as Map<String, dynamic>),
|
||||
journeyRef: json['journeyRef'] as String?,
|
||||
stops:
|
||||
(json['stops'] as List<dynamic>?)
|
||||
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <JourneyStop>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$LegToJson(_Leg instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'idx': instance.idx,
|
||||
'type': _$LegTypeEnumMap[instance.type]!,
|
||||
'name': instance.name,
|
||||
'category': instance.category,
|
||||
'number': instance.number,
|
||||
'direction': instance.direction,
|
||||
'origin': instance.origin,
|
||||
'destination': instance.destination,
|
||||
'duration': IsoDuration.toJson(instance.duration),
|
||||
'cancelled': instance.cancelled,
|
||||
'partCancelled': instance.partCancelled,
|
||||
'reachable': instance.reachable,
|
||||
'product': instance.product,
|
||||
'journeyRef': instance.journeyRef,
|
||||
'stops': instance.stops,
|
||||
};
|
||||
|
||||
const _$LegTypeEnumMap = {
|
||||
LegType.journey: 'JOURNEY',
|
||||
LegType.walk: 'WALK',
|
||||
LegType.transfer: 'TRANSFER',
|
||||
LegType.bike: 'BIKE',
|
||||
LegType.car: 'CAR',
|
||||
LegType.parkRide: 'PARK_RIDE',
|
||||
LegType.taxi: 'TAXI',
|
||||
LegType.checkIn: 'CHECK_IN',
|
||||
LegType.checkOut: 'CHECK_OUT',
|
||||
LegType.dummy: 'DUMMY',
|
||||
LegType.unknown: 'UNKNOWN',
|
||||
};
|
||||
|
||||
_Trip _$TripFromJson(Map<String, dynamic> json) => _Trip(
|
||||
tripId: json['tripId'] as String?,
|
||||
ctxRecon: json['ctxRecon'] as String?,
|
||||
checksum: json['checksum'] as String?,
|
||||
duration: IsoDuration.fromJson(json['duration'] as String?),
|
||||
realDuration: IsoDuration.fromJson(json['realDuration'] as String?),
|
||||
transferCount: (json['transferCount'] as num?)?.toInt(),
|
||||
legs:
|
||||
(json['legs'] as List<dynamic>?)
|
||||
?.map((e) => Leg.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <Leg>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TripToJson(_Trip instance) => <String, dynamic>{
|
||||
'tripId': instance.tripId,
|
||||
'ctxRecon': instance.ctxRecon,
|
||||
'checksum': instance.checksum,
|
||||
'duration': IsoDuration.toJson(instance.duration),
|
||||
'realDuration': IsoDuration.toJson(instance.realDuration),
|
||||
'transferCount': instance.transferCount,
|
||||
'legs': instance.legs,
|
||||
};
|
||||
|
||||
_TripSearchResult _$TripSearchResultFromJson(Map<String, dynamic> json) =>
|
||||
_TripSearchResult(
|
||||
trips:
|
||||
(json['trips'] as List<dynamic>?)
|
||||
?.map((e) => Trip.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <Trip>[],
|
||||
scrollContextLater: json['scrollContextLater'] as String?,
|
||||
scrollContextEarlier: json['scrollContextEarlier'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TripSearchResultToJson(_TripSearchResult instance) =>
|
||||
<String, dynamic>{
|
||||
'trips': instance.trips,
|
||||
'scrollContextLater': instance.scrollContextLater,
|
||||
'scrollContextEarlier': instance.scrollContextEarlier,
|
||||
};
|
||||
|
||||
_JourneyDetail _$JourneyDetailFromJson(Map<String, dynamic> json) =>
|
||||
_JourneyDetail(
|
||||
journeyId: json['journeyId'] as String?,
|
||||
product: json['product'] == null
|
||||
? null
|
||||
: Product.fromJson(json['product'] as Map<String, dynamic>),
|
||||
direction: json['direction'] as String?,
|
||||
stops:
|
||||
(json['stops'] as List<dynamic>?)
|
||||
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const <JourneyStop>[],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$JourneyDetailToJson(_JourneyDetail instance) =>
|
||||
<String, dynamic>{
|
||||
'journeyId': instance.journeyId,
|
||||
'product': instance.product,
|
||||
'direction': instance.direction,
|
||||
'stops': instance.stops,
|
||||
};
|
||||
|
||||
_HimMessage _$HimMessageFromJson(Map<String, dynamic> json) => _HimMessage(
|
||||
id: json['id'] as String,
|
||||
externalId: json['externalId'] as String?,
|
||||
head: json['head'] as String?,
|
||||
lead: json['lead'] as String?,
|
||||
text: json['text'] as String?,
|
||||
category: json['category'] as String?,
|
||||
company: json['company'] as String?,
|
||||
priority: (json['priority'] as num?)?.toInt(),
|
||||
products: (json['products'] as num?)?.toInt(),
|
||||
startValidity: json['startValidity'] == null
|
||||
? null
|
||||
: DateTime.parse(json['startValidity'] as String),
|
||||
endValidity: json['endValidity'] == null
|
||||
? null
|
||||
: DateTime.parse(json['endValidity'] as String),
|
||||
modified: json['modified'] == null
|
||||
? null
|
||||
: DateTime.parse(json['modified'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$HimMessageToJson(_HimMessage instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'externalId': instance.externalId,
|
||||
'head': instance.head,
|
||||
'lead': instance.lead,
|
||||
'text': instance.text,
|
||||
'category': instance.category,
|
||||
'company': instance.company,
|
||||
'priority': instance.priority,
|
||||
'products': instance.products,
|
||||
'startValidity': instance.startValidity?.toIso8601String(),
|
||||
'endValidity': instance.endValidity?.toIso8601String(),
|
||||
'modified': instance.modified?.toIso8601String(),
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
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,9 +0,0 @@
|
||||
import '../../marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||
|
||||
/// Demo fixtures for the app-maintenance breaker.
|
||||
class DemoBreaker {
|
||||
const DemoBreaker._();
|
||||
|
||||
/// No active rules — the demo app is never blocked.
|
||||
static GetBreakersResponse none() => GetBreakersResponse([]);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||
|
||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||
/// demo persona sees every feature (incl. push) as available.
|
||||
class DemoCapabilities {
|
||||
const DemoCapabilities._();
|
||||
|
||||
static CapabilitiesState state() => const CapabilitiesState(
|
||||
viewForeignTimetables: true,
|
||||
pushNotifications: true,
|
||||
loaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
|
||||
/// "everything enabled, nothing enforced" server profile.
|
||||
class DemoNextcloudCapabilities {
|
||||
const DemoNextcloudCapabilities._();
|
||||
|
||||
static NextcloudCapabilitiesState state() => const NextcloudCapabilitiesState(
|
||||
apiEnabled: true,
|
||||
publicEnabled: true,
|
||||
publicMultipleLinks: true,
|
||||
publicUploadEnabled: true,
|
||||
publicPasswordEnforced: false,
|
||||
publicExpireEnabled: false,
|
||||
publicExpireEnforced: false,
|
||||
groupEnabled: true,
|
||||
resharing: true,
|
||||
loaded: true,
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import '../../marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
|
||||
/// Curated WebDAV listings for the demo mode. Everything is built on
|
||||
/// [CacheableFile] (the app-level model the file UI renders) so no `nextcloud`
|
||||
/// DAV objects need to be faked and no network is ever touched.
|
||||
///
|
||||
/// `hasPreview`/`fileId` are left null on purpose: without them the file list
|
||||
/// won't try to fetch server-side thumbnails for the fixtures.
|
||||
class DemoFiles {
|
||||
const DemoFiles._();
|
||||
|
||||
/// Returns the listing for [path] as passed to `FilesDataProvider.listFiles`
|
||||
/// (`/` or empty for the root, otherwise the joined folder display names).
|
||||
/// Unknown folders resolve to an empty listing rather than an error.
|
||||
static ListFilesResponse listing(String path) {
|
||||
final key = path.replaceAll('/', '').trim();
|
||||
switch (key) {
|
||||
case '':
|
||||
return _root();
|
||||
case 'Hausaufgaben':
|
||||
return _hausaufgaben();
|
||||
case 'Arbeitsblätter':
|
||||
return _arbeitsblaetter();
|
||||
case 'Klausuren':
|
||||
return _klausuren();
|
||||
case 'Schuljahr 2025-26':
|
||||
return _schuljahr();
|
||||
default:
|
||||
return ListFilesResponse({});
|
||||
}
|
||||
}
|
||||
|
||||
static ListFilesResponse _root() => ListFilesResponse({
|
||||
_folder('Hausaufgaben', daysAgo: 1),
|
||||
_folder('Arbeitsblätter', daysAgo: 3),
|
||||
_folder('Klausuren', daysAgo: 12),
|
||||
_folder('Schuljahr 2025-26', daysAgo: 30),
|
||||
_file('Deutsch Lektüre.pdf', size: 2_480_000, daysAgo: 2),
|
||||
_file('Mathe Formelsammlung.pdf', size: 890_000, daysAgo: 9),
|
||||
_file('Bio Referat.pptx', size: 5_120_000, daysAgo: 5),
|
||||
_file('Stundenplan.pdf', size: 145_000, daysAgo: 14),
|
||||
});
|
||||
|
||||
static ListFilesResponse _hausaufgaben() => ListFilesResponse({
|
||||
_file(
|
||||
'Deutsch Aufsatz.docx',
|
||||
parent: 'Hausaufgaben',
|
||||
size: 42_000,
|
||||
daysAgo: 1,
|
||||
),
|
||||
_file(
|
||||
'Mathe Übungen KW28.pdf',
|
||||
parent: 'Hausaufgaben',
|
||||
size: 310_000,
|
||||
daysAgo: 2,
|
||||
),
|
||||
_file(
|
||||
'Englisch Vokabeln.pdf',
|
||||
parent: 'Hausaufgaben',
|
||||
size: 96_000,
|
||||
daysAgo: 4,
|
||||
),
|
||||
});
|
||||
|
||||
static ListFilesResponse _arbeitsblaetter() => ListFilesResponse({
|
||||
_file(
|
||||
'Physik Optik.pdf',
|
||||
parent: 'Arbeitsblätter',
|
||||
size: 720_000,
|
||||
daysAgo: 6,
|
||||
),
|
||||
_file(
|
||||
'Geschichte Weimar.pdf',
|
||||
parent: 'Arbeitsblätter',
|
||||
size: 540_000,
|
||||
daysAgo: 8,
|
||||
),
|
||||
});
|
||||
|
||||
static ListFilesResponse _klausuren() => ListFilesResponse({
|
||||
_file(
|
||||
'Mathe Klausur 1.pdf',
|
||||
parent: 'Klausuren',
|
||||
size: 630_000,
|
||||
daysAgo: 20,
|
||||
),
|
||||
_file(
|
||||
'Deutsch Klausur 1.pdf',
|
||||
parent: 'Klausuren',
|
||||
size: 580_000,
|
||||
daysAgo: 34,
|
||||
),
|
||||
});
|
||||
|
||||
static ListFilesResponse _schuljahr() => ListFilesResponse({
|
||||
_folder('Halbjahr 1', parent: 'Schuljahr 2025-26', daysAgo: 40),
|
||||
_file(
|
||||
'Klassenliste 10b.pdf',
|
||||
parent: 'Schuljahr 2025-26',
|
||||
size: 78_000,
|
||||
daysAgo: 60,
|
||||
),
|
||||
});
|
||||
|
||||
static CacheableFile _folder(
|
||||
String name, {
|
||||
String? parent,
|
||||
required int daysAgo,
|
||||
}) {
|
||||
final base = parent == null ? '' : '/$parent';
|
||||
return CacheableFile(
|
||||
path: '$base/$name/',
|
||||
isDirectory: true,
|
||||
name: name,
|
||||
modifiedAt: _relative(daysAgo),
|
||||
createdAt: _relative(daysAgo + 30),
|
||||
);
|
||||
}
|
||||
|
||||
static CacheableFile _file(
|
||||
String name, {
|
||||
String? parent,
|
||||
required int size,
|
||||
required int daysAgo,
|
||||
}) {
|
||||
final base = parent == null ? '' : '/$parent';
|
||||
return CacheableFile(
|
||||
path: '$base/$name',
|
||||
isDirectory: false,
|
||||
name: name,
|
||||
mimeType: _mimeFor(name),
|
||||
size: size,
|
||||
modifiedAt: _relative(daysAgo),
|
||||
createdAt: _relative(daysAgo + 30),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _relative(int daysAgo) =>
|
||||
DateTime.now().subtract(Duration(days: daysAgo, hours: 3));
|
||||
|
||||
static String _mimeFor(String name) {
|
||||
final ext = name.split('.').last.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
case 'pptx':
|
||||
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||
case 'docx':
|
||||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import '../../marianumconnect/models/mc_holiday.dart';
|
||||
|
||||
/// Demo fixtures for the holiday calendar — a couple of upcoming breaks,
|
||||
/// positioned relative to "now" so they always read as still ahead.
|
||||
class DemoHolidays {
|
||||
const DemoHolidays._();
|
||||
|
||||
static List<McHoliday> upcoming() {
|
||||
final today = DateTime.now();
|
||||
DateTime day(int offset) =>
|
||||
DateTime(today.year, today.month, today.day).add(Duration(days: offset));
|
||||
|
||||
return [
|
||||
McHoliday(
|
||||
shortName: 'Herbst',
|
||||
longName: 'Herbstferien',
|
||||
startDate: day(28),
|
||||
endDate: day(42),
|
||||
),
|
||||
McHoliday(
|
||||
shortName: 'Weihnachten',
|
||||
longName: 'Weihnachtsferien',
|
||||
startDate: day(90),
|
||||
endDate: day(104),
|
||||
),
|
||||
McHoliday(
|
||||
shortName: 'Ostern',
|
||||
longName: 'Osterferien',
|
||||
startDate: day(180),
|
||||
endDate: day(194),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Demo fixtures for the school calendar — a handful of upcoming events built
|
||||
/// relative to "now" and consistent with the demo persona (class 10b).
|
||||
class DemoMarianumDates {
|
||||
const DemoMarianumDates._();
|
||||
|
||||
static List<MarianumDate> events() {
|
||||
final today = DateTime.now();
|
||||
DateTime at(int offset, [int hour = 0, int minute = 0]) => DateTime(
|
||||
today.year,
|
||||
today.month,
|
||||
today.day,
|
||||
hour,
|
||||
minute,
|
||||
).add(Duration(days: offset));
|
||||
|
||||
return [
|
||||
MarianumDate(
|
||||
uid: 'demo-klausur-mathe',
|
||||
title: 'Klausur Mathematik ${DemoPersona.className}',
|
||||
description: 'Erste Klausur bei Herr Müller. Themen: Analysis.',
|
||||
start: at(4, 9, 45),
|
||||
end: at(4, 11, 15),
|
||||
isAllDay: false,
|
||||
),
|
||||
MarianumDate(
|
||||
uid: 'demo-wandertag',
|
||||
title: 'Wandertag',
|
||||
description: 'Gemeinsamer Ausflug der Jahrgangsstufe 10.',
|
||||
start: at(11),
|
||||
end: at(12),
|
||||
isAllDay: true,
|
||||
),
|
||||
MarianumDate(
|
||||
uid: 'demo-elternsprechtag',
|
||||
title: 'Elternsprechtag',
|
||||
description: 'Anmeldung zu den Gesprächen über das Sekretariat.',
|
||||
start: at(18, 14, 0),
|
||||
end: at(18, 19, 0),
|
||||
isAllDay: false,
|
||||
),
|
||||
MarianumDate(
|
||||
uid: 'demo-zeugniskonferenz',
|
||||
title: 'Zeugniskonferenz',
|
||||
description: 'Unterrichtsfrei ab der 4. Stunde.',
|
||||
start: at(32),
|
||||
end: at(33),
|
||||
isAllDay: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
|
||||
/// Demo fixtures for the info messages — a single friendly welcome entry so the
|
||||
/// section is populated rather than empty.
|
||||
class DemoMarianumMessage {
|
||||
const DemoMarianumMessage._();
|
||||
|
||||
static MarianumMessageList list() {
|
||||
final date = DateFormat.yMMMM('de').format(DateTime.now());
|
||||
|
||||
return MarianumMessageList(
|
||||
base: 'https://marianum-fulda.de',
|
||||
messages: [
|
||||
MarianumMessage(
|
||||
id: 'demo',
|
||||
name: 'Willkommen in der Marianum-App',
|
||||
date: date,
|
||||
description: 'Eine kurze Begrüßung zum Ausprobieren.',
|
||||
url: 'https://marianum-fulda.de',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Fixtures for the Talk module while [DemoMode] is active: a curated set of
|
||||
/// rooms plus a believable German conversation per room. Room tokens are the
|
||||
/// join between [rooms] and [chat] — they must stay in sync.
|
||||
class DemoTalk {
|
||||
const DemoTalk._();
|
||||
|
||||
static const String tokenClass = 'demo-klasse-10b';
|
||||
static const String tokenMathLk = 'demo-mathe-lk';
|
||||
static const String tokenWeber = 'demo-herr-weber';
|
||||
static const String tokenWeimar = 'demo-weimar';
|
||||
static const String tokenSv = 'demo-sv';
|
||||
|
||||
static GetRoomResponse rooms() {
|
||||
return GetRoomResponse({
|
||||
_room(
|
||||
id: 100,
|
||||
token: tokenClass,
|
||||
type: GetRoomResponseObjectConversationType.group,
|
||||
name: 'Klasse 10b',
|
||||
displayName: 'Klasse ${DemoPersona.className}',
|
||||
lastMessage: _lastOf(tokenClass),
|
||||
unreadMessages: 2,
|
||||
isFavorite: true,
|
||||
),
|
||||
_room(
|
||||
id: 200,
|
||||
token: tokenMathLk,
|
||||
type: GetRoomResponseObjectConversationType.group,
|
||||
name: 'Mathe-LK',
|
||||
displayName: 'Mathe-LK · Herr Müller',
|
||||
lastMessage: _lastOf(tokenMathLk),
|
||||
),
|
||||
_room(
|
||||
id: 300,
|
||||
token: tokenWeber,
|
||||
type: GetRoomResponseObjectConversationType.oneToOne,
|
||||
name: 'weber',
|
||||
displayName: 'Herr Weber',
|
||||
lastMessage: _lastOf(tokenWeber),
|
||||
unreadMessages: 1,
|
||||
),
|
||||
_room(
|
||||
id: 400,
|
||||
token: tokenWeimar,
|
||||
type: GetRoomResponseObjectConversationType.group,
|
||||
name: 'Fahrt nach Weimar',
|
||||
displayName: 'Fahrt nach Weimar 🚌',
|
||||
lastMessage: _lastOf(tokenWeimar),
|
||||
),
|
||||
_room(
|
||||
id: 500,
|
||||
token: tokenSv,
|
||||
type: GetRoomResponseObjectConversationType.group,
|
||||
name: 'SV',
|
||||
displayName: 'SV – Schülervertretung',
|
||||
lastMessage: _lastOf(tokenSv),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
static GetChatResponse chat(String token) =>
|
||||
GetChatResponse(_messages(token).toSet());
|
||||
|
||||
static GetChatResponseObject _lastOf(String token) => _messages(token).last;
|
||||
|
||||
static List<GetChatResponseObject> _messages(String token) {
|
||||
switch (token) {
|
||||
case tokenClass:
|
||||
return _classChat();
|
||||
case tokenMathLk:
|
||||
return _mathLkChat();
|
||||
case tokenWeber:
|
||||
return _weberChat();
|
||||
case tokenWeimar:
|
||||
return _weimarChat();
|
||||
case tokenSv:
|
||||
return _svChat();
|
||||
default:
|
||||
return _genericChat(token);
|
||||
}
|
||||
}
|
||||
|
||||
static List<GetChatResponseObject> _classChat() {
|
||||
const token = tokenClass;
|
||||
var base = 10000;
|
||||
return [
|
||||
_msg(
|
||||
id: base + 1,
|
||||
token: token,
|
||||
actor: 'sophie',
|
||||
display: 'Sophie',
|
||||
ago: const Duration(days: 1, hours: 4),
|
||||
message: 'Weiß jemand, was wir in Bio auf haben?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 2,
|
||||
token: token,
|
||||
actor: 'max',
|
||||
display: 'Max',
|
||||
ago: const Duration(days: 1, hours: 3, minutes: 55),
|
||||
message: 'S. 42 Nr. 3 und 4, glaube ich',
|
||||
),
|
||||
_selfMsg(
|
||||
id: base + 3,
|
||||
token: token,
|
||||
ago: const Duration(days: 1, hours: 3, minutes: 50),
|
||||
message: 'Ja genau, und das Arbeitsblatt zur Zellteilung 🙌',
|
||||
reactions: {'👍': 3},
|
||||
),
|
||||
_msg(
|
||||
id: base + 4,
|
||||
token: token,
|
||||
actor: 'jonas',
|
||||
display: 'Jonas',
|
||||
ago: const Duration(days: 1, hours: 3, minutes: 40),
|
||||
message: 'Danke euch! 🙏',
|
||||
),
|
||||
_msg(
|
||||
id: base + 5,
|
||||
token: token,
|
||||
actor: 'emma',
|
||||
display: 'Emma',
|
||||
ago: const Duration(hours: 20),
|
||||
message: 'Ist die Mathe-Arbeit jetzt am Donnerstag oder Freitag?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 6,
|
||||
token: token,
|
||||
actor: 'mueller',
|
||||
display: 'Herr Müller',
|
||||
ago: const Duration(hours: 19, minutes: 30),
|
||||
message: 'Die Arbeit findet am Freitag in der 3. Stunde statt.',
|
||||
),
|
||||
_msg(
|
||||
id: base + 7,
|
||||
token: token,
|
||||
actor: 'emma',
|
||||
display: 'Emma',
|
||||
ago: const Duration(hours: 19, minutes: 20),
|
||||
message: 'Danke, Herr Müller!',
|
||||
),
|
||||
_msg(
|
||||
id: base + 8,
|
||||
token: token,
|
||||
actor: 'paul',
|
||||
display: 'Paul',
|
||||
ago: const Duration(hours: 6),
|
||||
message: 'Wer kommt morgen mit in die Mensa?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 9,
|
||||
token: token,
|
||||
actor: 'mia',
|
||||
display: 'Mia',
|
||||
ago: const Duration(hours: 5, minutes: 50),
|
||||
message: 'Ich bin dabei 🍕',
|
||||
),
|
||||
_selfMsg(
|
||||
id: base + 10,
|
||||
token: token,
|
||||
ago: const Duration(hours: 5, minutes: 45),
|
||||
message: 'Ich auch!',
|
||||
),
|
||||
_msg(
|
||||
id: base + 11,
|
||||
token: token,
|
||||
actor: 'sophie',
|
||||
display: 'Sophie',
|
||||
ago: const Duration(hours: 3),
|
||||
message:
|
||||
'Vergesst nicht, die Einverständniserklärung für Weimar abzugeben 🚌',
|
||||
reactions: {'👍': 5, '🚌': 2},
|
||||
),
|
||||
_msg(
|
||||
id: base + 12,
|
||||
token: token,
|
||||
actor: 'ben',
|
||||
display: 'Ben',
|
||||
ago: const Duration(hours: 2, minutes: 10),
|
||||
message: 'Oh stimmt, hab ich fast vergessen 😅',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<GetChatResponseObject> _mathLkChat() {
|
||||
const token = tokenMathLk;
|
||||
var base = 20000;
|
||||
return [
|
||||
_msg(
|
||||
id: base + 1,
|
||||
token: token,
|
||||
actor: 'mueller',
|
||||
display: 'Herr Müller',
|
||||
ago: const Duration(days: 2, hours: 2),
|
||||
message:
|
||||
'Zur Vorbereitung auf die Arbeit findet ihr die Übungsblätter in den Dateien.',
|
||||
),
|
||||
_selfMsg(
|
||||
id: base + 2,
|
||||
token: token,
|
||||
ago: const Duration(days: 1, hours: 6),
|
||||
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 3,
|
||||
token: token,
|
||||
actor: 'mueller',
|
||||
display: 'Herr Müller',
|
||||
ago: const Duration(days: 1, hours: 5, minutes: 30),
|
||||
message: 'Klar, wir schauen uns die Kurvendiskussion gemeinsam an.',
|
||||
reactions: {'👍': 4},
|
||||
),
|
||||
_msg(
|
||||
id: base + 4,
|
||||
token: token,
|
||||
actor: 'jonas',
|
||||
display: 'Jonas',
|
||||
ago: const Duration(hours: 8),
|
||||
message: 'Super, danke!',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<GetChatResponseObject> _weberChat() {
|
||||
const token = tokenWeber;
|
||||
var base = 30000;
|
||||
return [
|
||||
_selfMsg(
|
||||
id: base + 1,
|
||||
token: token,
|
||||
ago: const Duration(days: 1, hours: 2),
|
||||
message:
|
||||
'Guten Tag Herr Weber, ich war letzte Woche krank. Was muss ich in Englisch nachholen?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 2,
|
||||
token: token,
|
||||
actor: 'weber',
|
||||
display: 'Herr Weber',
|
||||
ago: const Duration(hours: 4),
|
||||
message:
|
||||
'Hallo Lena, gute Besserung! Lies bitte Unit 4 und bereite die Vokabeln vor.',
|
||||
),
|
||||
_msg(
|
||||
id: base + 3,
|
||||
token: token,
|
||||
actor: 'weber',
|
||||
display: 'Herr Weber',
|
||||
ago: const Duration(hours: 3, minutes: 58),
|
||||
message: 'Die Präsentation kannst du nächste Woche nachholen.',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<GetChatResponseObject> _weimarChat() {
|
||||
const token = tokenWeimar;
|
||||
var base = 40000;
|
||||
return [
|
||||
_msg(
|
||||
id: base + 1,
|
||||
token: token,
|
||||
actor: 'wagner',
|
||||
display: 'Frau Wagner',
|
||||
ago: const Duration(days: 3),
|
||||
message:
|
||||
'Wir treffen uns am Montag um 7:45 Uhr am Haupteingang. Bitte pünktlich sein!',
|
||||
reactions: {'👍': 8},
|
||||
),
|
||||
_msg(
|
||||
id: base + 2,
|
||||
token: token,
|
||||
actor: 'paul',
|
||||
display: 'Paul',
|
||||
ago: const Duration(days: 2, hours: 20),
|
||||
message: 'Wie lange sind wir ungefähr unterwegs?',
|
||||
),
|
||||
_msg(
|
||||
id: base + 3,
|
||||
token: token,
|
||||
actor: 'wagner',
|
||||
display: 'Frau Wagner',
|
||||
ago: const Duration(days: 2, hours: 19),
|
||||
message: 'Etwa zwei Stunden mit dem Bus. Denkt an Verpflegung!',
|
||||
),
|
||||
_selfMsg(
|
||||
id: base + 4,
|
||||
token: token,
|
||||
ago: const Duration(hours: 12),
|
||||
message: 'Freue mich schon! 🎉',
|
||||
reactions: {'🎉': 6},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<GetChatResponseObject> _svChat() {
|
||||
const token = tokenSv;
|
||||
var base = 50000;
|
||||
return [
|
||||
_msg(
|
||||
id: base + 1,
|
||||
token: token,
|
||||
actor: 'ben',
|
||||
display: 'Ben',
|
||||
ago: const Duration(days: 2, hours: 5),
|
||||
message: 'Nächste SV-Sitzung ist am Mittwoch in der großen Pause.',
|
||||
),
|
||||
_msg(
|
||||
id: base + 2,
|
||||
token: token,
|
||||
actor: 'mia',
|
||||
display: 'Mia',
|
||||
ago: const Duration(days: 1, hours: 8),
|
||||
message: 'Können wir über den Ablauf vom Sommerfest sprechen?',
|
||||
),
|
||||
_selfMsg(
|
||||
id: base + 3,
|
||||
token: token,
|
||||
ago: const Duration(hours: 9),
|
||||
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
|
||||
reactions: {'👍': 3},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<GetChatResponseObject> _genericChat(String token) {
|
||||
return [
|
||||
_msg(
|
||||
id: 90001,
|
||||
token: token,
|
||||
actor: 'info',
|
||||
display: 'MarianumConnect',
|
||||
ago: const Duration(hours: 1),
|
||||
message: 'Willkommen im Demo-Chat 👋',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static GetChatResponseObject _selfMsg({
|
||||
required int id,
|
||||
required String token,
|
||||
required Duration ago,
|
||||
required String message,
|
||||
Map<String, int>? reactions,
|
||||
}) => _msg(
|
||||
id: id,
|
||||
token: token,
|
||||
actor: AccountData().getUsername(),
|
||||
display: DemoPersona.studentName,
|
||||
ago: ago,
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
);
|
||||
|
||||
static GetChatResponseObject _msg({
|
||||
required int id,
|
||||
required String token,
|
||||
required String actor,
|
||||
required String display,
|
||||
required Duration ago,
|
||||
required String message,
|
||||
Map<String, int>? reactions,
|
||||
}) => GetChatResponseObject(
|
||||
id,
|
||||
token,
|
||||
GetRoomResponseObjectMessageActorType.user,
|
||||
actor,
|
||||
display,
|
||||
_epoch(ago),
|
||||
'',
|
||||
GetRoomResponseObjectMessageType.comment,
|
||||
true,
|
||||
'demo-ref-$id',
|
||||
message,
|
||||
null,
|
||||
reactions,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
static GetRoomResponseObject _room({
|
||||
required int id,
|
||||
required String token,
|
||||
required GetRoomResponseObjectConversationType type,
|
||||
required String name,
|
||||
required String displayName,
|
||||
required GetChatResponseObject lastMessage,
|
||||
int unreadMessages = 0,
|
||||
bool isFavorite = false,
|
||||
}) => GetRoomResponseObject(
|
||||
id,
|
||||
token,
|
||||
type,
|
||||
name,
|
||||
displayName,
|
||||
'',
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
'demo-session-$id',
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
lastMessage.timestamp,
|
||||
isFavorite,
|
||||
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
|
||||
unreadMessages,
|
||||
false,
|
||||
false,
|
||||
lastMessage.id - unreadMessages,
|
||||
lastMessage.id,
|
||||
lastMessage,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
static int _epoch(Duration ago) =>
|
||||
DateTime.now().subtract(ago).millisecondsSinceEpoch ~/ 1000;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import '../../marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
||||
import '../../marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
||||
import '../../marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||
|
||||
/// Demo fixtures for the ticker module — a small, self-contained example doc so
|
||||
/// the "Aktuelles" surface and the page tree are populated without any network.
|
||||
class DemoTicker {
|
||||
const DemoTicker._();
|
||||
|
||||
static Map<String, dynamic> _welcomeDoc() => {
|
||||
'type': 'doc',
|
||||
'content': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'attrs': {'level': 1},
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Willkommen beim Ticker'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Hier erscheinen aktuelle Informationen '},
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'der Schule',
|
||||
'marks': [
|
||||
{'type': 'bold'},
|
||||
],
|
||||
},
|
||||
{'type': 'text', 'text': '.'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'callout',
|
||||
'attrs': {'variant': 'info'},
|
||||
'content': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Dies ist eine Beispiel-Meldung.'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
static Map<String, dynamic> _pageDoc() => {
|
||||
'type': 'doc',
|
||||
'content': [
|
||||
{
|
||||
'type': 'heading',
|
||||
'attrs': {'level': 2},
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'Über diese Seite'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'Eine Beispielseite mit nativ gerendertem Inhalt.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
static TickerResponse ticker() => TickerResponse(
|
||||
schemaVersion: 1,
|
||||
available: true,
|
||||
hash: 'demo-ticker',
|
||||
publishedAt: DateTime.now().toIso8601String(),
|
||||
webUrl: '/ticker',
|
||||
content: _welcomeDoc(),
|
||||
);
|
||||
|
||||
static TickerNavResponse nav() => TickerNavResponse(
|
||||
schemaVersion: 1,
|
||||
navHash: 'demo-nav',
|
||||
sections: [
|
||||
TickerNavSection(
|
||||
title: 'Informationen',
|
||||
pages: [
|
||||
TickerNavPage(
|
||||
title: 'Über die App',
|
||||
slug: 'ueber-die-app',
|
||||
kind: TickerPageKind.content,
|
||||
hash: 'demo-page',
|
||||
),
|
||||
TickerNavPage(
|
||||
title: 'Schulwebseite',
|
||||
slug: 'schulwebseite',
|
||||
kind: TickerPageKind.redirect,
|
||||
externalUrl: 'https://marianum-fulda.de',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
static TickerPageResponse page(String slug) => TickerPageResponse(
|
||||
schemaVersion: 1,
|
||||
slug: slug,
|
||||
title: 'Über die App',
|
||||
kind: TickerPageKind.content,
|
||||
content: _pageDoc(),
|
||||
webUrl: '/ticker/p/$slug',
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import '../../marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
|
||||
import '../../marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
|
||||
import '../../marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
|
||||
import '../../marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||
import '../../marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
||||
import '../../marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Curated timetable fixtures for the demo mode. Everything is derived from
|
||||
/// [DemoPersona] so subjects, teachers and rooms line up with the rest of the
|
||||
/// demo app, and every week is generated relative to the requested range so
|
||||
/// navigating forward/backward always lands on a populated 10b schedule.
|
||||
class DemoTimetable {
|
||||
const DemoTimetable._();
|
||||
|
||||
/// Period → (startHour, startMinute, endHour, endMinute).
|
||||
static const Map<int, (int, int, int, int)> _periods = {
|
||||
1: (7, 55, 8, 40),
|
||||
2: (8, 40, 9, 25),
|
||||
3: (9, 45, 10, 30),
|
||||
4: (10, 30, 11, 15),
|
||||
5: (11, 35, 12, 20),
|
||||
6: (12, 20, 13, 5),
|
||||
7: (13, 50, 14, 35),
|
||||
8: (14, 35, 15, 20),
|
||||
};
|
||||
|
||||
/// Weekday (DateTime.monday..friday) → period → subject short code.
|
||||
static const Map<int, Map<int, String>> _plan = {
|
||||
DateTime.monday: {1: 'M', 2: 'M', 3: 'D', 4: 'E', 5: 'BIO', 6: 'GE', 7: 'SP', 8: 'SP'},
|
||||
DateTime.tuesday: {1: 'E', 2: 'E', 3: 'PH', 4: 'M', 5: 'D', 6: 'REL', 7: 'GE'},
|
||||
DateTime.wednesday: {1: 'BIO', 2: 'BIO', 3: 'M', 4: 'D', 5: 'E', 6: 'PH'},
|
||||
DateTime.thursday: {1: 'D', 2: 'GE', 3: 'M', 4: 'M', 5: 'REL', 6: 'BIO', 7: 'SP', 8: 'SP'},
|
||||
DateTime.friday: {1: 'PH', 2: 'PH', 3: 'E', 4: 'M', 5: 'D', 6: 'GE'},
|
||||
};
|
||||
|
||||
static const Map<String, String> _roomBySubject = {
|
||||
'M': 'A101',
|
||||
'D': 'A101',
|
||||
'E': 'A102',
|
||||
'BIO': 'B201',
|
||||
'PH': 'B203',
|
||||
'GE': 'A101',
|
||||
'SP': 'Turnhalle 1',
|
||||
'REL': 'C305',
|
||||
};
|
||||
|
||||
static TimetableGetWeekResponse week(DateTime from, DateTime until) {
|
||||
final monday = _mondayOf(from);
|
||||
final entries = <McTimetableEntry>[];
|
||||
var id = 1;
|
||||
|
||||
for (final weekday in _plan.keys) {
|
||||
final date = monday.add(Duration(days: weekday - DateTime.monday));
|
||||
final day = _plan[weekday]!;
|
||||
for (final entry in day.entries) {
|
||||
entries.add(_entry(id++, date, weekday, entry.key, entry.value));
|
||||
}
|
||||
}
|
||||
|
||||
return TimetableGetWeekResponse(from: from, until: until, entries: entries);
|
||||
}
|
||||
|
||||
static McTimetableEntry _entry(
|
||||
int id,
|
||||
DateTime date,
|
||||
int weekday,
|
||||
int period,
|
||||
String subjectShort,
|
||||
) {
|
||||
final times = _periods[period]!;
|
||||
final teacher = _teacherFor(subjectShort);
|
||||
|
||||
// A couple of recurring irregularities so the demo shows off the cancelled
|
||||
// and substitution rendering.
|
||||
final cancelled = weekday == DateTime.monday && period == 4;
|
||||
final substituted = weekday == DateTime.tuesday && period == 3;
|
||||
|
||||
return McTimetableEntry(
|
||||
id: id,
|
||||
date: date,
|
||||
startTime: DateTime(1970, 1, 1, times.$1, times.$2),
|
||||
endTime: DateTime(1970, 1, 1, times.$3, times.$4),
|
||||
subjects: [subjectShort],
|
||||
teachers: [
|
||||
if (substituted)
|
||||
McTimetableTeacher(
|
||||
shortName: 'MÜL',
|
||||
displayName: 'Herr Müller',
|
||||
originalShortName: teacher.short,
|
||||
originalDisplayName: teacher.name,
|
||||
)
|
||||
else
|
||||
McTimetableTeacher(shortName: teacher.short, displayName: teacher.name),
|
||||
],
|
||||
rooms: [_roomBySubject[subjectShort] ?? DemoPersona.rooms.first],
|
||||
classNames: [DemoPersona.className],
|
||||
lessonType: 'LESSON',
|
||||
status: cancelled
|
||||
? 'CANCELLED'
|
||||
: substituted
|
||||
? 'IRREGULAR'
|
||||
: 'REGULAR',
|
||||
substitutionText: substituted ? 'Vertretung für ${teacher.name}' : null,
|
||||
lessonText: null,
|
||||
infoText: cancelled ? 'Fällt aus' : null,
|
||||
);
|
||||
}
|
||||
|
||||
static TimetableGetRoomsResponse rooms() => TimetableGetRoomsResponse(
|
||||
result: [
|
||||
for (var i = 0; i < DemoPersona.rooms.length; i++)
|
||||
McRoom(id: i + 1, shortName: DemoPersona.rooms[i], longName: DemoPersona.rooms[i]),
|
||||
],
|
||||
);
|
||||
|
||||
static TimetableGetSubjectsResponse subjects() => TimetableGetSubjectsResponse(
|
||||
result: [
|
||||
for (var i = 0; i < DemoPersona.subjects.length; i++)
|
||||
McSubject(
|
||||
id: i + 1,
|
||||
shortName: DemoPersona.subjects[i].short,
|
||||
longName: DemoPersona.subjects[i].name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
static TimetableGetTimegridResponse timegrid() {
|
||||
final units = <McTimegridUnit>[];
|
||||
for (final day in _weekdays) {
|
||||
for (final period in _periods.entries) {
|
||||
final t = period.value;
|
||||
units.add(
|
||||
McTimegridUnit(
|
||||
dayOfWeek: day,
|
||||
label: '${period.key}',
|
||||
startTime: DateTime(1970, 1, 1, t.$1, t.$2),
|
||||
endTime: DateTime(1970, 1, 1, t.$3, t.$4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return TimetableGetTimegridResponse(result: units);
|
||||
}
|
||||
|
||||
static TimetableGetSchoolyearResponse schoolyear() {
|
||||
final startYear = _schoolyearStart(DateTime.now());
|
||||
return TimetableGetSchoolyearResponse(
|
||||
id: 1,
|
||||
name: '$startYear/${startYear + 1}',
|
||||
startDate: DateTime(startYear, 8, 1),
|
||||
endDate: DateTime(startYear + 1, 7, 31),
|
||||
);
|
||||
}
|
||||
|
||||
static TimetableGetHolidaysResponse holidays() {
|
||||
final startYear = _schoolyearStart(DateTime.now());
|
||||
return TimetableGetHolidaysResponse(
|
||||
result: [
|
||||
McHoliday(
|
||||
shortName: 'Herbst',
|
||||
longName: 'Herbstferien',
|
||||
startDate: DateTime(startYear, 10, 20),
|
||||
endDate: DateTime(startYear, 10, 31),
|
||||
),
|
||||
McHoliday(
|
||||
shortName: 'Weihnachten',
|
||||
longName: 'Weihnachtsferien',
|
||||
startDate: DateTime(startYear, 12, 22),
|
||||
endDate: DateTime(startYear + 1, 1, 6),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static GetCustomTimetableEventResponse customEvents() =>
|
||||
GetCustomTimetableEventResponse([]);
|
||||
|
||||
static const List<McDayOfWeek> _weekdays = [
|
||||
McDayOfWeek.monday,
|
||||
McDayOfWeek.tuesday,
|
||||
McDayOfWeek.wednesday,
|
||||
McDayOfWeek.thursday,
|
||||
McDayOfWeek.friday,
|
||||
];
|
||||
|
||||
static DemoTeacher _teacherFor(String subjectShort) {
|
||||
final subjectName = DemoPersona.subjects
|
||||
.firstWhere((s) => s.short == subjectShort)
|
||||
.name;
|
||||
return DemoPersona.teachers.firstWhere((t) => t.subject == subjectName);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime date) {
|
||||
final day = DateTime(date.year, date.month, date.day);
|
||||
return day.subtract(Duration(days: day.weekday - DateTime.monday));
|
||||
}
|
||||
|
||||
static int _schoolyearStart(DateTime now) =>
|
||||
now.month >= 8 ? now.year : now.year - 1;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import '../../marianumconnect/queries/user_search/user_search_response.dart';
|
||||
|
||||
/// Demo fixtures for the Talk user search — a small mixed set of teachers and
|
||||
/// students, filtered client-side so the demo search feels responsive.
|
||||
class DemoUsers {
|
||||
const DemoUsers._();
|
||||
|
||||
static final List<McUserSearchResult> _all = [
|
||||
McUserSearchResult(
|
||||
username: 'm.muster',
|
||||
firstName: 'Maria',
|
||||
lastName: 'Mustermann',
|
||||
userType: 'TEACHER',
|
||||
),
|
||||
McUserSearchResult(
|
||||
username: 'j.beispiel',
|
||||
firstName: 'Jonas',
|
||||
lastName: 'Beispiel',
|
||||
userType: 'TEACHER',
|
||||
),
|
||||
McUserSearchResult(
|
||||
username: 'l.schueler',
|
||||
firstName: 'Lena',
|
||||
lastName: 'Schüler',
|
||||
userType: 'STUDENT',
|
||||
className: '9c',
|
||||
),
|
||||
McUserSearchResult(
|
||||
username: 'p.probe',
|
||||
firstName: 'Paul',
|
||||
lastName: 'Probe',
|
||||
userType: 'STUDENT',
|
||||
className: 'Q2',
|
||||
),
|
||||
];
|
||||
|
||||
static List<McUserSearchResult> search(String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.length < 2) return const [];
|
||||
return _all
|
||||
.where(
|
||||
(u) =>
|
||||
u.firstName.toLowerCase().contains(q) ||
|
||||
u.lastName.toLowerCase().contains(q) ||
|
||||
u.username.toLowerCase().contains(q) ||
|
||||
'${u.firstName} ${u.lastName}'.toLowerCase().contains(q),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import 'data/demo_absence.dart';
|
||||
import 'data/demo_breaker.dart';
|
||||
import 'data/demo_holidays.dart';
|
||||
import 'data/demo_timetable.dart';
|
||||
import 'data/demo_users.dart';
|
||||
|
||||
/// Single source of truth for the MarianumConnect demo responses. The demo
|
||||
/// interceptor asks this for the body of any MC request. Read endpoints reuse
|
||||
/// the typed fixtures and serialize them to the exact shape the matching query
|
||||
/// parses (a bare list vs. an object); write/side-effect endpoints (telemetry,
|
||||
/// client-errors, auth, push, feedback) have no consumed body and resolve to an
|
||||
/// empty success.
|
||||
class DemoMarianumConnect {
|
||||
const DemoMarianumConnect._();
|
||||
|
||||
/// Body for [path] (relative to `/api/mobile/v1/`), using [query] for the
|
||||
/// timetable date window.
|
||||
static Object responseBody(String path, Map<String, dynamic> query) {
|
||||
switch (path) {
|
||||
case 'timetable/me':
|
||||
return DemoTimetable.week(
|
||||
_date(query['from']),
|
||||
_date(query['until']),
|
||||
).toJson();
|
||||
case 'timetable/schoolyear':
|
||||
return DemoTimetable.schoolyear().toJson();
|
||||
case 'timetable/rooms':
|
||||
return DemoTimetable.rooms().result.map((e) => e.toJson()).toList();
|
||||
case 'timetable/subjects':
|
||||
return DemoTimetable.subjects().result.map((e) => e.toJson()).toList();
|
||||
case 'timetable/timegrid':
|
||||
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
||||
case 'timetable/holidays':
|
||||
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||
case 'holidays':
|
||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||
case 'users/search':
|
||||
return DemoUsers.search(query['q']?.toString() ?? '')
|
||||
.map((e) => e.toJson())
|
||||
.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':
|
||||
return const <dynamic>[];
|
||||
}
|
||||
// A foreign element's week (timetable/<type>/<id>) shows the same demo week.
|
||||
if (path.startsWith('timetable/')) {
|
||||
return DemoTimetable.week(
|
||||
_date(query['from']),
|
||||
_date(query['until']),
|
||||
).toJson();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
static DateTime _date(Object? raw) =>
|
||||
DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'demo_marianumconnect.dart';
|
||||
import 'demo_mode.dart';
|
||||
|
||||
/// Answers every MarianumConnect request from fixtures while a demo session is
|
||||
/// active, so the app makes no MC network calls. Installed first on the MC dio
|
||||
/// so it resolves before the auth interceptor tries to attach a (non-existent)
|
||||
/// bearer token.
|
||||
class DemoMarianumConnectInterceptor extends Interceptor {
|
||||
static const _apiMarker = '/api/mobile/v1/';
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (!DemoMode.active) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
handler.resolve(
|
||||
Response<dynamic>(
|
||||
requestOptions: options,
|
||||
statusCode: 200,
|
||||
data: DemoMarianumConnect.responseBody(
|
||||
_relativePath(options.uri),
|
||||
options.queryParameters,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _relativePath(Uri uri) {
|
||||
final i = uri.path.indexOf(_apiMarker);
|
||||
return i >= 0 ? uri.path.substring(i + _apiMarker.length) : uri.path;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import '../../model/account_data.dart';
|
||||
|
||||
/// Central switch for the client-side demo mode.
|
||||
///
|
||||
/// A login whose username starts with [usernamePrefix] (the password is
|
||||
/// ignored) enters demo mode: instead of talking to the real backends, the app
|
||||
/// serves curated fixtures from `DemoData` for every feature. This lets Google
|
||||
/// 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
|
||||
/// survives cold starts exactly like a normal login. It works in release builds
|
||||
/// too — reviewers run the shipped release build.
|
||||
class DemoMode {
|
||||
const DemoMode._();
|
||||
|
||||
/// Usernames starting with this prefix trigger demo mode. Whatever the
|
||||
/// reviewer types as the password is accepted.
|
||||
static const String usernamePrefix = 'demo@';
|
||||
|
||||
/// Whether [username], as typed on the login screen, should enter demo mode.
|
||||
static bool matches(String username) =>
|
||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||
|
||||
/// True while the active session is a demo session.
|
||||
static bool get active => AccountData().isDemo;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/// Shared identity for the demo mode so fixtures across every feature describe
|
||||
/// the same fictional student, class, teachers and rooms — the timetable, chats
|
||||
/// and grades all line up. See DemoMode.
|
||||
class DemoPersona {
|
||||
const DemoPersona._();
|
||||
|
||||
static const String studentFirstName = 'Lena';
|
||||
static const String studentName = 'Lena Hoffmann';
|
||||
static const String className = '10b';
|
||||
|
||||
static const List<DemoTeacher> teachers = [
|
||||
DemoTeacher('Herr Müller', 'MÜL', 'Mathematik'),
|
||||
DemoTeacher('Frau Schmidt', 'SMT', 'Deutsch'),
|
||||
DemoTeacher('Herr Weber', 'WEB', 'Englisch'),
|
||||
DemoTeacher('Frau Becker', 'BEC', 'Biologie'),
|
||||
DemoTeacher('Herr Hofmann', 'HOF', 'Physik'),
|
||||
DemoTeacher('Frau Wagner', 'WAG', 'Geschichte'),
|
||||
DemoTeacher('Herr Klein', 'KLN', 'Sport'),
|
||||
DemoTeacher('Frau Richter', 'RIC', 'Religion'),
|
||||
];
|
||||
|
||||
static const List<DemoSubject> subjects = [
|
||||
DemoSubject('Mathematik', 'M', 0xFF4E79A7),
|
||||
DemoSubject('Deutsch', 'D', 0xFFE15759),
|
||||
DemoSubject('Englisch', 'E', 0xFFF28E2B),
|
||||
DemoSubject('Biologie', 'BIO', 0xFF59A14F),
|
||||
DemoSubject('Physik', 'PH', 0xFF76B7B2),
|
||||
DemoSubject('Geschichte', 'GE', 0xFFB07AA1),
|
||||
DemoSubject('Sport', 'SP', 0xFFEDC948),
|
||||
DemoSubject('Religion', 'REL', 0xFF9C755F),
|
||||
];
|
||||
|
||||
static const List<String> rooms = [
|
||||
'A101',
|
||||
'A102',
|
||||
'B201',
|
||||
'B203',
|
||||
'C305',
|
||||
'Turnhalle 1',
|
||||
'Aula',
|
||||
];
|
||||
}
|
||||
|
||||
class DemoTeacher {
|
||||
final String name;
|
||||
final String short;
|
||||
final String subject;
|
||||
const DemoTeacher(this.name, this.short, this.subject);
|
||||
}
|
||||
|
||||
class DemoSubject {
|
||||
final String name;
|
||||
final String short;
|
||||
|
||||
/// ARGB color used where the UI colors a lesson/subject.
|
||||
final int color;
|
||||
const DemoSubject(this.name, this.short, this.color);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'emergency_notice.dart';
|
||||
|
||||
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
|
||||
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
|
||||
/// Never throws: any failure yields `null` (nothing shown).
|
||||
///
|
||||
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
|
||||
/// it lives only for the process, so a cold start always fetches fresh.
|
||||
class EmergencyNoticeClient {
|
||||
EmergencyNoticeClient();
|
||||
|
||||
static const Duration cacheTtl = Duration(minutes: 1);
|
||||
|
||||
EmergencyNotice? _cached;
|
||||
String? _cachedUrl;
|
||||
DateTime? _cachedAt;
|
||||
|
||||
Future<EmergencyNotice?> fetch(String url) async {
|
||||
final cachedAt = _cachedAt;
|
||||
if (cachedAt != null &&
|
||||
_cachedUrl == url &&
|
||||
DateTime.now().difference(cachedAt) < cacheTtl) {
|
||||
return _cached;
|
||||
}
|
||||
|
||||
EmergencyNotice? result;
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
final response = await dio.get<String>(url);
|
||||
final raw = response.data;
|
||||
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
|
||||
} catch (_) {
|
||||
result = null;
|
||||
}
|
||||
|
||||
// Cache failures too, so a down server isn't retried on every resume.
|
||||
_cached = result;
|
||||
_cachedUrl = url;
|
||||
_cachedAt = DateTime.now();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,16 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../api_error.dart';
|
||||
import '../http_errors.dart';
|
||||
import '../marianumcloud/talk/talk_error.dart';
|
||||
import '../webuntis/webuntis_error.dart';
|
||||
import 'app_exception.dart';
|
||||
import 'auth_exception.dart';
|
||||
import 'network_exception.dart';
|
||||
import 'not_found_exception.dart';
|
||||
import 'parse_exception.dart';
|
||||
import 'server_exception.dart';
|
||||
import 'talk_exception.dart';
|
||||
import 'webuntis_exception.dart';
|
||||
|
||||
const String _defaultFallback =
|
||||
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
|
||||
@@ -27,7 +25,6 @@ AppException? _dioToAppException(DioException error) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.transformTimeout:
|
||||
return NetworkException.timeout(technicalDetails: error.message);
|
||||
case DioExceptionType.connectionError:
|
||||
return NetworkException(technicalDetails: error.message);
|
||||
@@ -55,47 +52,18 @@ AppException? _dioToAppException(DioException error) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
||||
/// Its toString() dumps every response header, so the details keep only the
|
||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||
final status = error.statusCode;
|
||||
final preview = previewBody(error.body);
|
||||
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||
switch (status) {
|
||||
case 401:
|
||||
return AuthException.unauthorized(technicalDetails: detail);
|
||||
case 403:
|
||||
return AuthException.forbidden(technicalDetails: detail);
|
||||
case 404:
|
||||
return NotFoundException(technicalDetails: detail);
|
||||
case 429:
|
||||
return ServerException(
|
||||
statusCode: status,
|
||||
userMessage:
|
||||
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
|
||||
technicalDetails: detail,
|
||||
);
|
||||
default:
|
||||
return ServerException(statusCode: status, technicalDetails: detail);
|
||||
}
|
||||
}
|
||||
|
||||
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||
if (error == null) return fallback;
|
||||
if (error is AppException) return error.userMessage;
|
||||
|
||||
if (error is TalkError) return TalkException(error).userMessage;
|
||||
if (error is WebuntisError) return WebuntisException(error).userMessage;
|
||||
|
||||
if (error is DioException) {
|
||||
final mapped = _dioToAppException(error);
|
||||
if (mapped != null) return mapped.userMessage;
|
||||
}
|
||||
|
||||
if (error is DynamiteApiException) {
|
||||
return _dynamiteToAppException(error).userMessage;
|
||||
}
|
||||
|
||||
if (error is SocketException) {
|
||||
return const NetworkException().userMessage;
|
||||
}
|
||||
@@ -122,14 +90,11 @@ String? errorToTechnicalDetails(Object? error) {
|
||||
if (error == null) return null;
|
||||
if (error is AppException) return error.technicalDetails ?? error.toString();
|
||||
if (error is TalkError) return TalkException(error).technicalDetails;
|
||||
if (error is WebuntisError) return WebuntisException(error).technicalDetails;
|
||||
if (error is DioException) {
|
||||
final mapped = _dioToAppException(error);
|
||||
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
|
||||
}
|
||||
if (error is DynamiteApiException) {
|
||||
final mapped = _dynamiteToAppException(error);
|
||||
return mapped.technicalDetails ?? mapped.toString();
|
||||
}
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
@@ -140,9 +105,6 @@ bool errorAllowsRetry(Object? error) {
|
||||
final mapped = _dioToAppException(error);
|
||||
if (mapped != null) return mapped.allowRetry;
|
||||
}
|
||||
if (error is DynamiteApiException) {
|
||||
return _dynamiteToAppException(error).allowRetry;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,9 @@ class ServerException extends AppException {
|
||||
String? userMessage,
|
||||
super.technicalDetails,
|
||||
}) : super(
|
||||
// Status code intentionally not in the user message — it lives in
|
||||
// technicalDetails behind "Details anzeigen".
|
||||
userMessage:
|
||||
userMessage ??
|
||||
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
||||
'Bitte versuche es in einem Moment erneut.',
|
||||
'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.',
|
||||
allowRetry: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ class TalkException extends AppException {
|
||||
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
|
||||
default:
|
||||
if (e.code >= 500) {
|
||||
return 'Der Chat-Server konnte die Anfrage gerade nicht verarbeiten. '
|
||||
'Bitte versuche es in einem Moment erneut.';
|
||||
return 'Talk-Server hat gerade Probleme (${e.code}).';
|
||||
}
|
||||
return e.message.isNotEmpty
|
||||
? e.message
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
/// Raised when a CONTENT ticker page has no native ProseMirror payload yet
|
||||
/// (404 `CONTENT_UNAVAILABLE`). Carries the site-relative [webUrl] so the UI
|
||||
/// can offer to open the page in the browser instead. Not retryable — the
|
||||
/// content only appears after an admin re-saves the page.
|
||||
class TickerContentUnavailableException extends AppException {
|
||||
final String? webUrl;
|
||||
|
||||
const TickerContentUnavailableException({
|
||||
this.webUrl,
|
||||
super.technicalDetails,
|
||||
}) : super(
|
||||
userMessage:
|
||||
'Diese Seite ist in der App noch nicht verfügbar. '
|
||||
'Du kannst sie im Browser öffnen.',
|
||||
allowRetry: false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../webuntis/webuntis_error.dart';
|
||||
import 'app_exception.dart';
|
||||
|
||||
class WebuntisException extends AppException {
|
||||
final WebuntisError source;
|
||||
|
||||
WebuntisException(this.source)
|
||||
: super(
|
||||
userMessage: _mapMessage(source),
|
||||
technicalDetails: 'WebUntis (${source.code}): ${source.message}',
|
||||
allowRetry: true,
|
||||
);
|
||||
|
||||
static String _mapMessage(WebuntisError e) {
|
||||
switch (e.code) {
|
||||
case -8504:
|
||||
case -8502:
|
||||
return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.';
|
||||
case -8520:
|
||||
return 'Bitte melde dich erneut an.';
|
||||
case -7004:
|
||||
return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.';
|
||||
case -32601:
|
||||
return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.';
|
||||
default:
|
||||
return e.message.isNotEmpty
|
||||
? 'WebUntis: ${e.message}'
|
||||
: 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'nominatim_result.freezed.dart';
|
||||
part 'nominatim_result.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class NominatimResult with _$NominatimResult {
|
||||
const factory NominatimResult({
|
||||
required String displayName,
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) = _NominatimResult;
|
||||
|
||||
factory NominatimResult.fromJson(Map<String, Object?> json) =>
|
||||
_$NominatimResultFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// 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 'nominatim_result.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$NominatimResult {
|
||||
|
||||
String get displayName; double get lat; double get lon;
|
||||
/// Create a copy of NominatimResult
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$NominatimResultCopyWith<NominatimResult> get copyWith => _$NominatimResultCopyWithImpl<NominatimResult>(this as NominatimResult, _$identity);
|
||||
|
||||
/// Serializes this NominatimResult to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $NominatimResultCopyWith<$Res> {
|
||||
factory $NominatimResultCopyWith(NominatimResult value, $Res Function(NominatimResult) _then) = _$NominatimResultCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String displayName, double lat, double lon
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$NominatimResultCopyWithImpl<$Res>
|
||||
implements $NominatimResultCopyWith<$Res> {
|
||||
_$NominatimResultCopyWithImpl(this._self, this._then);
|
||||
|
||||
final NominatimResult _self;
|
||||
final $Res Function(NominatimResult) _then;
|
||||
|
||||
/// Create a copy of NominatimResult
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
|
||||
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [NominatimResult].
|
||||
extension NominatimResultPatterns on NominatimResult {
|
||||
/// 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( _NominatimResult value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _NominatimResult() 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( _NominatimResult value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _NominatimResult():
|
||||
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( _NominatimResult value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _NominatimResult() 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 displayName, double lat, double lon)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NominatimResult() when $default != null:
|
||||
return $default(_that.displayName,_that.lat,_that.lon);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 displayName, double lat, double lon) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NominatimResult():
|
||||
return $default(_that.displayName,_that.lat,_that.lon);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 displayName, double lat, double lon)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _NominatimResult() when $default != null:
|
||||
return $default(_that.displayName,_that.lat,_that.lon);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _NominatimResult implements NominatimResult {
|
||||
const _NominatimResult({required this.displayName, required this.lat, required this.lon});
|
||||
factory _NominatimResult.fromJson(Map<String, dynamic> json) => _$NominatimResultFromJson(json);
|
||||
|
||||
@override final String displayName;
|
||||
@override final double lat;
|
||||
@override final double lon;
|
||||
|
||||
/// Create a copy of NominatimResult
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$NominatimResultCopyWith<_NominatimResult> get copyWith => __$NominatimResultCopyWithImpl<_NominatimResult>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$NominatimResultToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$NominatimResultCopyWith<$Res> implements $NominatimResultCopyWith<$Res> {
|
||||
factory _$NominatimResultCopyWith(_NominatimResult value, $Res Function(_NominatimResult) _then) = __$NominatimResultCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String displayName, double lat, double lon
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$NominatimResultCopyWithImpl<$Res>
|
||||
implements _$NominatimResultCopyWith<$Res> {
|
||||
__$NominatimResultCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _NominatimResult _self;
|
||||
final $Res Function(_NominatimResult) _then;
|
||||
|
||||
/// Create a copy of NominatimResult
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
|
||||
return _then(_NominatimResult(
|
||||
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
|
||||
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -0,0 +1,21 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'nominatim_result.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_NominatimResult _$NominatimResultFromJson(Map<String, dynamic> json) =>
|
||||
_NominatimResult(
|
||||
displayName: json['displayName'] as String,
|
||||
lat: (json['lat'] as num).toDouble(),
|
||||
lon: (json['lon'] as num).toDouble(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$NominatimResultToJson(_NominatimResult instance) =>
|
||||
<String, dynamic>{
|
||||
'displayName': instance.displayName,
|
||||
'lat': instance.lat,
|
||||
'lon': instance.lon,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import '../errors/server_exception.dart';
|
||||
import 'nominatim_result.dart';
|
||||
|
||||
/// Tiny wrapper around the public Nominatim geocoder. Only used in the
|
||||
/// commute-settings flow to look up a home address; not called from any
|
||||
/// hot path. The User-Agent header is **required** by the Nominatim usage
|
||||
/// policy — without it the service throttles/blocks the client.
|
||||
class NominatimSearch {
|
||||
static const _userAgent = 'MarianumMobile/1.0 (contact@elias-mueller.com)';
|
||||
static final Uri _base = Uri.parse('https://nominatim.openstreetmap.org/search');
|
||||
|
||||
/// Returns up to [limit] geocoded matches for the user-typed [query].
|
||||
Future<List<NominatimResult>> run(String query, {int limit = 5}) async {
|
||||
final uri = _base.replace(
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': limit.toString(),
|
||||
'addressdetails': '0',
|
||||
'accept-language': 'de',
|
||||
},
|
||||
);
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http
|
||||
.get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'application/json'})
|
||||
.timeout(const Duration(seconds: 15));
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'nominatim: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
|
||||
}
|
||||
|
||||
if (response.statusCode > 299) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'nominatim HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final raw = jsonDecode(utf8.decode(response.bodyBytes)) as List;
|
||||
return raw
|
||||
.map((e) => _resultFromRaw(e as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
} catch (e) {
|
||||
throw ParseException(technicalDetails: 'nominatim assemble: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static NominatimResult _resultFromRaw(Map<String, dynamic> json) {
|
||||
// Nominatim returns lat/lon as strings, not numbers. Normalise here.
|
||||
final lat = double.parse(json['lat'].toString());
|
||||
final lon = double.parse(json['lon'].toString());
|
||||
return NominatimResult(
|
||||
displayName: json['display_name'] as String? ?? '?',
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'errors/auth_exception.dart';
|
||||
import 'errors/network_exception.dart';
|
||||
import 'errors/not_found_exception.dart';
|
||||
import 'errors/server_exception.dart';
|
||||
|
||||
/// Runs [send] and converts transport-level failures (socket/timeout/client
|
||||
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
|
||||
/// Passes through whatever [send] produces, including `null` for the base-class
|
||||
/// request hooks that may skip the call.
|
||||
Future<http.Response?> sendGuarded(
|
||||
String label,
|
||||
Future<http.Response>? Function() send,
|
||||
) async {
|
||||
try {
|
||||
return await send();
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: '$label: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
|
||||
/// embedded in an [AppException]'s technical details without dumping headers.
|
||||
String previewBody(String body) {
|
||||
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}…' : collapsed;
|
||||
}
|
||||
|
||||
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
|
||||
String httpErrorDetail(String label, String body, int status) {
|
||||
final preview = previewBody(body);
|
||||
return preview.isEmpty
|
||||
? '$label -> HTTP $status'
|
||||
: '$label -> HTTP $status body=$preview';
|
||||
}
|
||||
|
||||
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
|
||||
/// [detail] as technical details: 401/403 map to auth errors, 404 to
|
||||
/// not-found, everything else to a generic server error.
|
||||
Never throwForStatus(int status, String detail) {
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Revokes an app password server-side via
|
||||
/// `DELETE /ocs/v2.php/core/apppassword`. The endpoint revokes the credential
|
||||
/// the request authenticates WITH — by default the shared OCS headers (general
|
||||
/// app password); pass [authorizationHeader] to revoke another one (the Talk
|
||||
/// app password). Best-effort: the result is ignored — logout clears local
|
||||
/// state regardless.
|
||||
class DeleteAppPassword {
|
||||
final http.Client _client;
|
||||
|
||||
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
Future<void> run({String? authorizationHeader}) async {
|
||||
await _client.delete(
|
||||
NextcloudOcs.uri('core/apppassword'),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
'Authorization': ?authorizationHeader,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.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]),
|
||||
/// which is what the push-v2 registration binds to.
|
||||
///
|
||||
/// Must authenticate with the *real* password — an app password cannot mint
|
||||
/// another one.
|
||||
class GetAppPassword {
|
||||
final http.Client _client;
|
||||
|
||||
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.
|
||||
Future<String> run() async {
|
||||
final response = await _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(),
|
||||
},
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
||||
}
|
||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final data = (json as Map)['ocs']?['data'];
|
||||
final appPassword = data is Map ? data['apppassword'] as String? : null;
|
||||
if (appPassword == null || appPassword.isEmpty) {
|
||||
throw Exception('getapppassword: no apppassword in response');
|
||||
}
|
||||
return appPassword;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,32 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'autocomplete_response.dart';
|
||||
|
||||
class AutocompleteApi {
|
||||
/// Searches sharees (users by default). Pass [shareTypes] to widen the search
|
||||
/// — e.g. `[0, 1]` for both users and groups (0 = user, 1 = group).
|
||||
Future<AutocompleteResponse> find(
|
||||
String query, {
|
||||
List<int> shareTypes = const [0],
|
||||
}) async {
|
||||
// NextcloudOcs.uri serialises every query value via `toString()`, which
|
||||
// would turn the `shareTypes[]` list into `"[0, 1]"`. Build the Uri here so
|
||||
// Dart encodes the list as repeated `shareTypes[]=0&shareTypes[]=1` params.
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
final uri = Uri.https(
|
||||
endpoint.domain,
|
||||
'${endpoint.path}/ocs/v2.php/core/autocomplete/get',
|
||||
{
|
||||
'format': 'json',
|
||||
Future<AutocompleteResponse> find(String query) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'core/autocomplete/get',
|
||||
queryParameters: {
|
||||
'search': query,
|
||||
'itemType': ' ',
|
||||
'itemId': ' ',
|
||||
'shareTypes[]': shareTypes.map((t) => t.toString()).toList(),
|
||||
'shareTypes[]': ['0'],
|
||||
'limit': '10',
|
||||
},
|
||||
);
|
||||
final response = await http.get(uri, headers: NextcloudOcs.headers());
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||
throw Exception(
|
||||
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AutocompleteResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../files_sharing/queries/share/share.dart';
|
||||
|
||||
part 'autocomplete_response.g.dart';
|
||||
|
||||
/// Maps an autocomplete result's `source` to the matching Nextcloud share type.
|
||||
/// Groups become [kShareTypeGroup]; everything else (users, and any unknown
|
||||
/// source) defaults to [kShareTypeUser].
|
||||
int shareTypeFromSource(String? source) {
|
||||
if (source != null && source.startsWith('groups')) return kShareTypeGroup;
|
||||
return kShareTypeUser;
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class AutocompleteResponse {
|
||||
List<AutocompleteResponseObject> data;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'nextcloud_sharing_capabilities.dart';
|
||||
|
||||
/// Fetches the current user's Nextcloud capabilities via OCS
|
||||
/// `GET cloud/capabilities` and extracts the `files_sharing` block. This is the
|
||||
/// per-user, group-aware source of truth the sharing UI gates on — no custom
|
||||
/// backend involved.
|
||||
class GetNextcloudCapabilities {
|
||||
Future<NextcloudSharingCapabilities> run() async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'cloud/capabilities',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'cloud/capabilities: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(decoded['ocs'] as Map<String, dynamic>?)?['data']
|
||||
as Map<String, dynamic>?;
|
||||
final capabilities = data?['capabilities'] as Map<String, dynamic>?;
|
||||
final filesSharing = capabilities?['files_sharing'];
|
||||
if (filesSharing is! Map<String, dynamic>) {
|
||||
// Server doesn't advertise files_sharing (app disabled) — treat as no
|
||||
// sharing capability rather than failing the whole load.
|
||||
return const NextcloudSharingCapabilities();
|
||||
}
|
||||
final passwordPolicy = capabilities?['password_policy'];
|
||||
return NextcloudSharingCapabilities.fromFilesSharing(
|
||||
filesSharing,
|
||||
passwordPolicy: passwordPolicy is Map<String, dynamic>
|
||||
? passwordPolicy
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/// Subset of Nextcloud's `files_sharing` capabilities block that the mobile
|
||||
/// sharing UI gates on. Nextcloud reports these per authenticated user, so a
|
||||
/// group that an admin excluded from creating public links sees
|
||||
/// `public.enabled == false` here — exactly how the web UI hides those buttons.
|
||||
///
|
||||
/// The block is deeply nested and varies between server versions, so this is
|
||||
/// parsed by hand from the raw OCS map with safe fallbacks rather than via
|
||||
/// code generation. Missing fields default to the most restrictive value so a
|
||||
/// newer/older server never accidentally unlocks a capability.
|
||||
class NextcloudSharingCapabilities {
|
||||
/// `files_sharing.api_enabled` — master switch. When false the user may not
|
||||
/// create any share (user, group or link).
|
||||
final bool apiEnabled;
|
||||
|
||||
/// `files_sharing.public.enabled` — public link shares allowed.
|
||||
final bool publicEnabled;
|
||||
|
||||
/// `files_sharing.public.multiple_links` — more than one link per file.
|
||||
final bool publicMultipleLinks;
|
||||
|
||||
/// `files_sharing.public.upload` — public upload / file-drop folders.
|
||||
final bool publicUploadEnabled;
|
||||
|
||||
/// `files_sharing.public.password.enforced` — a password is mandatory on
|
||||
/// public links, so the create flow must collect one upfront.
|
||||
final bool publicPasswordEnforced;
|
||||
|
||||
/// `files_sharing.public.expire_date.enabled`.
|
||||
final bool publicExpireEnabled;
|
||||
|
||||
/// `files_sharing.public.expire_date.days` — default/maximum lifetime.
|
||||
final int? publicExpireDays;
|
||||
|
||||
/// `files_sharing.public.expire_date.enforced` — expiry cannot be removed.
|
||||
final bool publicExpireEnforced;
|
||||
|
||||
/// `files_sharing.group.enabled` (falls back to the older `group_sharing`).
|
||||
final bool groupEnabled;
|
||||
|
||||
/// `files_sharing.resharing` — recipients may reshare.
|
||||
final bool resharing;
|
||||
|
||||
// --- password_policy (a sibling capability of files_sharing) ---
|
||||
// These let the link-password UI state the rules up front instead of only
|
||||
// surfacing them after the server rejects a weak password. The
|
||||
// "non-common password" (breach) check can only be enforced server-side.
|
||||
|
||||
/// `password_policy.minLength`.
|
||||
final int? passwordMinLength;
|
||||
|
||||
/// `password_policy.enforceUpperLowerCase`.
|
||||
final bool passwordEnforceUpperLower;
|
||||
|
||||
/// `password_policy.enforceNumericCharacters`.
|
||||
final bool passwordEnforceNumeric;
|
||||
|
||||
/// `password_policy.enforceSpecialCharacters`.
|
||||
final bool passwordEnforceSpecial;
|
||||
|
||||
const NextcloudSharingCapabilities({
|
||||
this.apiEnabled = false,
|
||||
this.publicEnabled = false,
|
||||
this.publicMultipleLinks = false,
|
||||
this.publicUploadEnabled = false,
|
||||
this.publicPasswordEnforced = false,
|
||||
this.publicExpireEnabled = false,
|
||||
this.publicExpireDays,
|
||||
this.publicExpireEnforced = false,
|
||||
this.groupEnabled = false,
|
||||
this.resharing = false,
|
||||
this.passwordMinLength,
|
||||
this.passwordEnforceUpperLower = false,
|
||||
this.passwordEnforceNumeric = false,
|
||||
this.passwordEnforceSpecial = false,
|
||||
});
|
||||
|
||||
/// Parses the `files_sharing` sub-map of an OCS `cloud/capabilities`
|
||||
/// response, plus the optional sibling `password_policy` map. Tolerates
|
||||
/// missing intermediate maps and type drift.
|
||||
factory NextcloudSharingCapabilities.fromFilesSharing(
|
||||
Map<String, dynamic> filesSharing, {
|
||||
Map<String, dynamic>? passwordPolicy,
|
||||
}) {
|
||||
Map<String, dynamic>? sub(Map<String, dynamic>? m, String key) {
|
||||
final value = m?[key];
|
||||
return value is Map<String, dynamic> ? value : null;
|
||||
}
|
||||
|
||||
bool boolAt(Map<String, dynamic>? m, String key) => m?[key] == true;
|
||||
int? intAt(Map<String, dynamic>? m, String key) {
|
||||
final v = m?[key];
|
||||
if (v is int) return v;
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
final public = sub(filesSharing, 'public');
|
||||
final password = sub(public, 'password');
|
||||
final expire = sub(public, 'expire_date');
|
||||
final group = sub(filesSharing, 'group');
|
||||
|
||||
return NextcloudSharingCapabilities(
|
||||
apiEnabled: boolAt(filesSharing, 'api_enabled'),
|
||||
publicEnabled: boolAt(public, 'enabled'),
|
||||
publicMultipleLinks: boolAt(public, 'multiple_links'),
|
||||
publicUploadEnabled: boolAt(public, 'upload'),
|
||||
publicPasswordEnforced: boolAt(password, 'enforced'),
|
||||
publicExpireEnabled: boolAt(expire, 'enabled'),
|
||||
publicExpireDays: intAt(expire, 'days'),
|
||||
publicExpireEnforced: boolAt(expire, 'enforced'),
|
||||
// Newer servers nest it under `group.enabled`; older ones expose a flat
|
||||
// `group_sharing` boolean.
|
||||
groupEnabled:
|
||||
boolAt(group, 'enabled') || boolAt(filesSharing, 'group_sharing'),
|
||||
resharing: boolAt(filesSharing, 'resharing'),
|
||||
passwordMinLength: intAt(passwordPolicy, 'minLength'),
|
||||
passwordEnforceUpperLower: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceUpperLowerCase',
|
||||
),
|
||||
passwordEnforceNumeric: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceNumericCharacters',
|
||||
),
|
||||
passwordEnforceSpecial: boolAt(
|
||||
passwordPolicy,
|
||||
'enforceSpecialCharacters',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Mix of two Nextcloud surfaces:
|
||||
/// - User info comes from the OCS provisioning API
|
||||
/// (`/ocs/v2.php/cloud/users/{userId}`).
|
||||
/// - The own-avatar upload/delete uses the *core* AvatarController at
|
||||
/// `/avatar/` — the OCS provisioning route has no POST (it answers 405).
|
||||
/// This is the same controller the read path (`/avatar/{id}/{size}` in
|
||||
/// [UserAvatar]) already talks to. CSRF is bypassed because we use Basic
|
||||
/// Auth without a session cookie.
|
||||
|
||||
/// Core AvatarController endpoint for the logged-in user (POST sets, DELETE
|
||||
/// removes). Built against the bare Nextcloud base (domain + optional path),
|
||||
/// not the OCS wrapper.
|
||||
Uri _coreAvatarUri() {
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
||||
}
|
||||
|
||||
Uri _userInfoUri() =>
|
||||
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
|
||||
|
||||
Future<http.Response> _send(
|
||||
Future<http.Response> Function(Uri uri, Map<String, String> headers)
|
||||
perform,
|
||||
Uri uri,
|
||||
) async {
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final response = (await sendGuarded(
|
||||
'Cloud $uri',
|
||||
() => perform(uri, headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status >= 200 && status < 300) return response;
|
||||
|
||||
final detail = httpErrorDetail('Cloud $uri', response.body, status);
|
||||
log(detail);
|
||||
throwForStatus(status, detail);
|
||||
}
|
||||
|
||||
class SetUserAvatar {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
SetUserAvatar(this.bytes, {this.filename = 'avatar.jpg'});
|
||||
|
||||
Future<void> run() async {
|
||||
await _send((uri, headers) async {
|
||||
// Core AvatarController reads $_FILES['files']['error'][0] — the field
|
||||
// must be `files[]` so PHP exposes it as an array, matching the web UI.
|
||||
final req = http.MultipartRequest('POST', uri)
|
||||
..headers.addAll(headers)
|
||||
..files.add(
|
||||
http.MultipartFile.fromBytes('files[]', bytes, filename: filename),
|
||||
);
|
||||
final streamed = await req.send();
|
||||
return http.Response.fromStream(streamed);
|
||||
}, _coreAvatarUri());
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteUserAvatar {
|
||||
Future<void> run() async {
|
||||
await _send(
|
||||
(uri, headers) => http.delete(uri, headers: headers),
|
||||
_coreAvatarUri(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CloudUserInfo {
|
||||
final String userId;
|
||||
final String displayName;
|
||||
const CloudUserInfo({required this.userId, required this.displayName});
|
||||
}
|
||||
|
||||
/// Reads the current user's provisioning record. The OCS wrapper looks like:
|
||||
/// `{ "ocs": { "meta": {...}, "data": { "id": "...", "displayname": "...", ... } } }`.
|
||||
/// We only need displayname; everything else is discarded.
|
||||
class GetUserInfo {
|
||||
Future<CloudUserInfo> run() async {
|
||||
final uri = _userInfoUri();
|
||||
final response = await _send(
|
||||
(u, headers) => http.get(u, headers: headers),
|
||||
uri,
|
||||
);
|
||||
try {
|
||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'file_sharing_api_params.dart';
|
||||
import 'queries/share/share.dart';
|
||||
import 'queries/share/share_update_params.dart';
|
||||
|
||||
/// OCS `files_sharing` API (Nextcloud Sharing API v1). Per the official docs:
|
||||
/// list/delete pass parameters via the URL (query / path), create and update
|
||||
/// pass them in the request **body** (form-urlencoded), and every call adds
|
||||
/// `format=json` so the server replies with JSON instead of XML.
|
||||
///
|
||||
/// All calls surface failures as [ServerException] so they map to friendly
|
||||
/// messages via `errorToUserMessage`.
|
||||
class FileSharingApi {
|
||||
static const String _base = 'apps/files_sharing/api/v1/shares';
|
||||
|
||||
/// Creates a share. Returns the created [Share] (callers that don't need it —
|
||||
/// e.g. Talk file sharing — can ignore the result).
|
||||
Future<Share> share(FileSharingApiParams query) async {
|
||||
Future<void> share(FileSharingApiParams query) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
_base,
|
||||
queryParameters: {'format': 'json'},
|
||||
'apps/files_sharing/api/v1/shares',
|
||||
queryParameters: query.toJson(),
|
||||
);
|
||||
final response = await http.post(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: _stringForm(query.toJson()),
|
||||
);
|
||||
return _decodeShare(response, action: 'Freigabe erstellen');
|
||||
}
|
||||
|
||||
/// Lists shares for the given OCS [path] (see `ocs_path.dart`). [reshares]
|
||||
/// includes shares the current user received and re-shared.
|
||||
Future<List<Share>> listForPath(String path, {bool reshares = false}) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
_base,
|
||||
queryParameters: {
|
||||
'format': 'json',
|
||||
'path': path,
|
||||
'reshares': reshares.toString(),
|
||||
'subfiles': 'false',
|
||||
},
|
||||
);
|
||||
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||
final data = _decodeData(response, action: 'Freigaben laden');
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Share.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Updates an existing share. Returns the updated [Share].
|
||||
Future<Share> update(int shareId, ShareUpdateParams params) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'$_base/$shareId',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.put(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: params.toQuery(),
|
||||
);
|
||||
return _decodeShare(response, action: 'Freigabe ändern');
|
||||
}
|
||||
|
||||
/// Deletes (revokes) a share.
|
||||
Future<void> remove(int shareId) async {
|
||||
final endpoint = NextcloudOcs.uri(
|
||||
'$_base/$shareId',
|
||||
queryParameters: {'format': 'json'},
|
||||
);
|
||||
final response = await http.delete(
|
||||
endpoint,
|
||||
headers: NextcloudOcs.headers(),
|
||||
);
|
||||
_decodeData(response, action: 'Freigabe löschen');
|
||||
}
|
||||
|
||||
/// Stringifies a json map into form fields (the OCS body is
|
||||
/// `application/x-www-form-urlencoded`). Null values are already dropped by
|
||||
/// the params' `includeIfNull: false`.
|
||||
Map<String, String> _stringForm(Map<String, dynamic> json) =>
|
||||
json.map((key, value) => MapEntry(key, value.toString()));
|
||||
|
||||
/// Decodes a single-share response (create/update). Throws if the payload is
|
||||
/// not a share object.
|
||||
Share _decodeShare(http.Response response, {required String action}) {
|
||||
final data = _decodeData(response, action: action);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'Unerwartete Antwort für "$action": ${response.body}',
|
||||
final response = await http.post(endpoint, headers: NextcloudOcs.headers());
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw Exception(
|
||||
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
return Share.fromJson(data);
|
||||
}
|
||||
|
||||
/// Validates the HTTP/OCS envelope and returns the `ocs.data` payload, or
|
||||
/// throws a [ServerException] carrying the server's OCS message when present.
|
||||
///
|
||||
/// Every access is type-checked rather than cast: PHP's `json_encode` turns
|
||||
/// an empty associative array into a JSON array (`[]`), and `statuscode` can
|
||||
/// arrive as a string — a hard `as` cast on either would throw a raw
|
||||
/// TypeError that surfaces as the generic "something went wrong" message.
|
||||
Object? _decodeData(http.Response response, {required String action}) {
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} catch (_) {
|
||||
decoded = null;
|
||||
}
|
||||
final ocs = decoded is Map<String, dynamic> ? decoded['ocs'] : null;
|
||||
final ocsMap = ocs is Map<String, dynamic> ? ocs : null;
|
||||
final meta = ocsMap?['meta'];
|
||||
final metaMap = meta is Map<String, dynamic> ? meta : null;
|
||||
|
||||
final rawStatus = metaMap?['statuscode'];
|
||||
final ocsStatus = rawStatus is int
|
||||
? rawStatus
|
||||
: (rawStatus is String ? int.tryParse(rawStatus) : null);
|
||||
final rawMessage = metaMap?['message'];
|
||||
final ocsMessage = rawMessage is String ? rawMessage : null;
|
||||
|
||||
// OCS v2 mirrors the HTTP status; accept any 2xx. Success OCS statuscodes
|
||||
// are 100 (v1 carry-over) or 200.
|
||||
final httpOk = response.statusCode >= 200 && response.statusCode < 300;
|
||||
final ocsOk = ocsStatus == null || ocsStatus == 100 || ocsStatus == 200;
|
||||
if (!httpOk || !ocsOk) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
userMessage: ocsMessage != null && ocsMessage.isNotEmpty
|
||||
? '$action fehlgeschlagen: $ocsMessage'
|
||||
: null,
|
||||
technicalDetails: '$action: ${response.statusCode} ${response.body}',
|
||||
);
|
||||
}
|
||||
return ocsMap?['data'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'file_sharing_api_params.g.dart';
|
||||
|
||||
// includeIfNull:false so the optional sharing fields below are only sent when
|
||||
// set — otherwise the OCS query would carry `permissions=null` etc. and be
|
||||
// rejected.
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
@JsonSerializable()
|
||||
class FileSharingApiParams {
|
||||
int shareType;
|
||||
String shareWith;
|
||||
@@ -13,24 +10,12 @@ class FileSharingApiParams {
|
||||
String? referenceId;
|
||||
String? talkMetaData;
|
||||
|
||||
/// Permission bitmask (see `share_permissions.dart`).
|
||||
int? permissions;
|
||||
|
||||
/// Public link password.
|
||||
String? password;
|
||||
|
||||
/// Expiry as `YYYY-MM-DD`.
|
||||
String? expireDate;
|
||||
|
||||
FileSharingApiParams({
|
||||
required this.shareType,
|
||||
required this.shareWith,
|
||||
required this.path,
|
||||
this.referenceId,
|
||||
this.talkMetaData,
|
||||
this.permissions,
|
||||
this.password,
|
||||
this.expireDate,
|
||||
});
|
||||
|
||||
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -14,9 +14,6 @@ FileSharingApiParams _$FileSharingApiParamsFromJson(
|
||||
path: json['path'] as String,
|
||||
referenceId: json['referenceId'] as String?,
|
||||
talkMetaData: json['talkMetaData'] as String?,
|
||||
permissions: (json['permissions'] as num?)?.toInt(),
|
||||
password: json['password'] as String?,
|
||||
expireDate: json['expireDate'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||
@@ -25,9 +22,6 @@ Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||
'shareType': instance.shareType,
|
||||
'shareWith': instance.shareWith,
|
||||
'path': instance.path,
|
||||
'referenceId': ?instance.referenceId,
|
||||
'talkMetaData': ?instance.talkMetaData,
|
||||
'permissions': ?instance.permissions,
|
||||
'password': ?instance.password,
|
||||
'expireDate': ?instance.expireDate,
|
||||
'referenceId': instance.referenceId,
|
||||
'talkMetaData': instance.talkMetaData,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user