Compare commits
59 Commits
9b74c9fd81
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb22a497d | |||
| 889d8f67c5 | |||
| 39c16bd4ea | |||
| b9cb1df473 | |||
| 62fa337188 | |||
| c0dadf8b6e | |||
| ab23422a86 | |||
| 646e2c0451 | |||
| 246cb0f527 | |||
| 4c2e9b47e7 | |||
| 2d690736e3 | |||
| 778c473631 | |||
| 7e9cbcf1e9 | |||
| 32be67426c | |||
| b957189fd3 | |||
| 624c5512a6 | |||
| 3493cf8617 | |||
| 75080a2c49 | |||
| f3cd7896d9 | |||
| 101e7c909c | |||
| 429f1e6f96 | |||
| a0c55a811c | |||
| e625216a90 | |||
| 8128cede21 | |||
| e5f7cf0176 | |||
| e349d667d4 | |||
| 7c1f5c06df | |||
| e8c6ac1c65 | |||
| 478f0ff20b | |||
| 8274dd46cd | |||
| d7536ea5d0 | |||
| 398b147c76 | |||
| 15791423ea | |||
| 2f5a6b4ce0 | |||
| 53bc6d5360 | |||
| f50359b4eb | |||
| 9994a1f3fa | |||
| 4aa31a2e44 | |||
| dfce3e7b5c | |||
| 564a334cdc | |||
| db329c7299 | |||
| 0a2ff5c3fb | |||
| 9b5198c6db | |||
| 94794ff092 | |||
| fe2b3c43b2 | |||
| a7111844b1 | |||
| babc347b18 | |||
| 44e45c9b78 | |||
| 3f44e9302f | |||
| 59501d3b45 | |||
| 91a6216f66 | |||
| c444ed54a5 | |||
| ff23199345 | |||
| 9545d2a946 | |||
| 37608e59b3 | |||
| 0d01f6b631 | |||
| b0d2e7a34b | |||
| cedeb06569 | |||
| 1114291313 |
+12
@@ -350,3 +350,15 @@ hs_err_pid*
|
|||||||
|
|
||||||
*.idea*
|
*.idea*
|
||||||
**/.DS_store
|
**/.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
|
||||||
|
|||||||
@@ -65,5 +65,9 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'com.android.support:multidex:2.0.1'
|
implementation 'com.android.support:multidex:2.0.1'
|
||||||
|
// Same version as workmanager_android pins — needed to enqueue its
|
||||||
|
// BackgroundWorker from native widget code (the plugin uses
|
||||||
|
// `implementation`, so androidx.work is not exposed transitively).
|
||||||
|
implementation 'androidx.work:work-runtime:2.11.2'
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
|||||||
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||||
*/
|
*/
|
||||||
class TimetableDayWidget : AppWidgetProvider() {
|
class TimetableDayWidget : AppWidgetProvider() {
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
// First widget of this kind placed → fetch fresh data right away.
|
||||||
|
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||||
|
|
||||||
class TimetableWeekWidget : AppWidgetProvider() {
|
class TimetableWeekWidget : AppWidgetProvider() {
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
// First widget of this kind placed → fetch fresh data right away.
|
||||||
|
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ data class WidgetLesson(
|
|||||||
val subjectShort: String,
|
val subjectShort: String,
|
||||||
val subjectLong: String?,
|
val subjectLong: String?,
|
||||||
val room: String?,
|
val room: String?,
|
||||||
|
// On teacher accounts this carries the class label ("7a") instead of the
|
||||||
|
// teacher short name (originalTeacher is null then) — mapped in Dart.
|
||||||
val teacher: String?,
|
val teacher: String?,
|
||||||
val originalTeacher: String?,
|
val originalTeacher: String?,
|
||||||
val status: WidgetLessonStatus,
|
val status: WidgetLessonStatus,
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package eu.mhsl.marianum.mobile.client.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dev.fluttercommunity.workmanager.BackgroundWorker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues the app's Dart one-off widget refresh from native code, so a
|
||||||
|
* freshly placed widget populates within seconds instead of waiting for the
|
||||||
|
* next periodic slot.
|
||||||
|
*
|
||||||
|
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
|
||||||
|
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
|
||||||
|
* persisted by Workmanager().initialize(); if the app never ran, the worker
|
||||||
|
* fails gracefully and the widget keeps rendering its cached/empty state.
|
||||||
|
*/
|
||||||
|
object WidgetRefreshRequester {
|
||||||
|
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
|
||||||
|
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
|
||||||
|
|
||||||
|
fun enqueueOneOffRefresh(context: Context) {
|
||||||
|
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||||
|
.setInputData(
|
||||||
|
Data.Builder()
|
||||||
|
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueue(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -736,8 +736,11 @@ object WidgetRenderer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||||
|
// schema bump must land in Dart, Swift (WidgetData.swift) and here
|
||||||
|
// together, or the out-of-sync platform silently renders empty.
|
||||||
const val KEY_DAY_DATA = "widget_data_day_v1"
|
const val KEY_DAY_DATA = "widget_data_day_v1"
|
||||||
const val KEY_WEEK_DATA = "widget_data_week_v1"
|
const val KEY_WEEK_DATA = "widget_data_week_v2"
|
||||||
const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
|
const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
|
||||||
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
|
||||||
|
# Plugin-Handling initialisiert.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 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`).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
#
|
||||||
|
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
|
||||||
|
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
|
||||||
|
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
|
||||||
|
#
|
||||||
|
# Notfall-Nachricht der MarianumMobile-App
|
||||||
|
#
|
||||||
|
# Diese Datei wird bei jedem App-Start geladen.
|
||||||
|
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
|
||||||
|
#
|
||||||
|
# Steuerfelder:
|
||||||
|
# active: true schaltet die Anzeige ein (Default: false)
|
||||||
|
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
|
||||||
|
# title: optionale Überschrift
|
||||||
|
#
|
||||||
|
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
|
||||||
|
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
|
||||||
|
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
|
||||||
|
# Beispielkonfiguration:
|
||||||
|
#
|
||||||
|
# ---
|
||||||
|
# # Ein Kommentar
|
||||||
|
# active: true
|
||||||
|
# dismissible: false
|
||||||
|
# title: Wichtiger Hinweis
|
||||||
|
# ---
|
||||||
|
# Hinweistext in Markdown
|
||||||
|
#
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
active: false
|
||||||
|
dismissible: true
|
||||||
|
title: Hinweis
|
||||||
|
---
|
||||||
|
# Serverstörung
|
||||||
|
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
|
||||||
|
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
|
||||||
|
|
||||||
|
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
@@ -31,7 +31,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
||||||
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
||||||
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
||||||
private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||||
|
|
||||||
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
|
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
|
||||||
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
||||||
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
/// kSecClass = kSecClassGenericPassword
|
/// kSecClass = kSecClassGenericPassword
|
||||||
/// kSecAttrAccount = the Dart key, verbatim
|
/// kSecAttrAccount = the Dart key, verbatim
|
||||||
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
||||||
/// kSecAttrAccessGroup = the App Group id
|
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
|
||||||
/// value = raw UTF-8 bytes of the string
|
/// value = raw UTF-8 bytes of the string
|
||||||
private func keychainString(_ account: String) -> String? {
|
private func keychainString(_ account: String) -> String? {
|
||||||
let query: [CFString: Any] = [
|
let query: [CFString: Any] = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+36
-10
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
|
|||||||
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
||||||
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
||||||
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
||||||
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
|
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
|
||||||
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
|
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
|
||||||
|
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
|
||||||
|
|
||||||
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
||||||
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
||||||
@@ -67,6 +68,15 @@ iOS zeigt die fertige Notification
|
|||||||
|
|
||||||
## 3. Xcode-Checkliste (auf dem Mac)
|
## 3. Xcode-Checkliste (auf dem Mac)
|
||||||
|
|
||||||
|
> **Stand 2026-07-07:** Abschnitte 3.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
|
### 3.1 Target anlegen
|
||||||
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
||||||
2. **File → New → Target… → iOS → Notification Service Extension**.
|
2. **File → New → Target… → iOS → Notification Service Extension**.
|
||||||
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
|
|||||||
## 4. Ermittelte Keychain-Details (verbindlich)
|
## 4. Ermittelte Keychain-Details (verbindlich)
|
||||||
|
|
||||||
Die Dart-Seite schreibt mit
|
Die Dart-Seite schreibt mit
|
||||||
`IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`.
|
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
|
||||||
|
|
||||||
|
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
|
||||||
|
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
|
||||||
|
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
|
||||||
|
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
|
||||||
|
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
|
||||||
|
> Automatic Signing mit „doesn't match the entitlements file's value for the
|
||||||
|
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
|
||||||
|
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
|
||||||
|
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
|
||||||
|
|
||||||
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||||
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
||||||
|
|
||||||
@@ -141,7 +162,7 @@ Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
|||||||
| `kSecClass` | `kSecClassGenericPassword` |
|
| `kSecClass` | `kSecClassGenericPassword` |
|
||||||
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
||||||
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
||||||
| `kSecAttrAccessGroup` | `group.eu.mhsl.marianum.mobile.client.widget` |
|
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
|
||||||
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
||||||
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
||||||
|
|
||||||
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
|
|||||||
innerhalb des NSE-Budgets). Nicht implementiert.
|
innerhalb des NSE-Budgets). Nicht implementiert.
|
||||||
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
||||||
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
||||||
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
|
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
|
||||||
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
|
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
|
||||||
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
|
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
|
||||||
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
|
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
|
||||||
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
|
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
|
||||||
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
|
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
|
||||||
|
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
|
||||||
|
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
|
||||||
|
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
|
||||||
|
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
|
||||||
|
vollqualifizierten Group-String verwenden.
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ PODS:
|
|||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_app_badge (2.0.0):
|
- flutter_app_badge (2.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- home_widget (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- open_filex (0.0.2):
|
- open_filex (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- PhoneNumberKit (3.7.11):
|
- PhoneNumberKit (3.7.11):
|
||||||
@@ -14,8 +12,6 @@ PODS:
|
|||||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||||
- PhoneNumberKit/UIKit (3.7.11):
|
- PhoneNumberKit/UIKit (3.7.11):
|
||||||
- PhoneNumberKit/PhoneNumberKitCore
|
- PhoneNumberKit/PhoneNumberKitCore
|
||||||
- receive_sharing_intent (1.8.1):
|
|
||||||
- Flutter
|
|
||||||
- workmanager_apple (0.0.1):
|
- workmanager_apple (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
@@ -23,10 +19,8 @@ DEPENDENCIES:
|
|||||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
|
||||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||||
- PhoneNumberKit (~> 3.7.6)
|
- PhoneNumberKit (~> 3.7.6)
|
||||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
|
||||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -40,12 +34,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_app_badge:
|
flutter_app_badge:
|
||||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||||
home_widget:
|
|
||||||
:path: ".symlinks/plugins/home_widget/ios"
|
|
||||||
open_filex:
|
open_filex:
|
||||||
:path: ".symlinks/plugins/open_filex/ios"
|
:path: ".symlinks/plugins/open_filex/ios"
|
||||||
receive_sharing_intent:
|
|
||||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
|
||||||
workmanager_apple:
|
workmanager_apple:
|
||||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||||
|
|
||||||
@@ -53,10 +43,8 @@ SPEC CHECKSUMS:
|
|||||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
|
||||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||||
|
|
||||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||||
|
|||||||
@@ -12,14 +12,18 @@
|
|||||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
|
||||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
||||||
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -37,6 +41,13 @@
|
|||||||
remoteGlobalIDString = AA0101010000000011111111;
|
remoteGlobalIDString = AA0101010000000011111111;
|
||||||
remoteInfo = TimetableWidgetExtension;
|
remoteInfo = TimetableWidgetExtension;
|
||||||
};
|
};
|
||||||
|
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
|
||||||
|
remoteInfo = NotificationServiceExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -48,6 +59,7 @@
|
|||||||
files = (
|
files = (
|
||||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
||||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -65,17 +77,24 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
|
||||||
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||||
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -94,10 +113,12 @@
|
|||||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
|
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
|
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
|
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
|
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
|
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
|
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -169,6 +190,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -185,15 +214,36 @@
|
|||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
|
||||||
|
509DCCD474353408FE5806C5 /* PEM.swift */,
|
||||||
|
12B96C78930441C73F123636 /* Info.plist */,
|
||||||
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||||
|
);
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
path = NotificationServiceExtension;
|
||||||
|
sourceTree = SOURCE_ROOT;
|
||||||
|
};
|
||||||
731388A08E3B330B216381D0 /* Frameworks */ = {
|
731388A08E3B330B216381D0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
||||||
|
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -208,6 +258,9 @@
|
|||||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
||||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
||||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
||||||
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
|
||||||
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
|
||||||
|
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Flutter;
|
name = Flutter;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -222,6 +275,7 @@
|
|||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
345F4BD4143471FDA71626DE /* Pods */,
|
345F4BD4143471FDA71626DE /* Pods */,
|
||||||
731388A08E3B330B216381D0 /* Frameworks */,
|
731388A08E3B330B216381D0 /* Frameworks */,
|
||||||
|
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -231,6 +285,7 @@
|
|||||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
||||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
||||||
|
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -278,9 +333,6 @@
|
|||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
packageProductDependencies = (
|
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
);
|
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
@@ -299,8 +351,12 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
||||||
AA0101090000000011111111 /* PBXTargetDependency */,
|
AA0101090000000011111111 /* PBXTargetDependency */,
|
||||||
|
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -325,13 +381,27 @@
|
|||||||
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
|
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
DF8C1170E7DF96711030EAC0 /* Sources */,
|
||||||
|
D2447D92E9CCD96B3292B17B /* Frameworks */,
|
||||||
|
239068341DC6E6B36193EC96 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
productName = NotificationServiceExtension;
|
||||||
|
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
packageReferences = (
|
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
|
||||||
);
|
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
@@ -360,6 +430,9 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -367,11 +440,19 @@
|
|||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
3321F8042FB1C00C0011C712 /* Share Extension */,
|
3321F8042FB1C00C0011C712 /* Share Extension */,
|
||||||
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
||||||
|
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
239068341DC6E6B36193EC96 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
3321F8032FB1C00C0011C712 /* Resources */ = {
|
3321F8032FB1C00C0011C712 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -520,6 +601,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
DF8C1170E7DF96711030EAC0 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
|
||||||
|
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -528,6 +618,12 @@
|
|||||||
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
||||||
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
|
||||||
|
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
||||||
@@ -929,6 +1025,29 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
A2B046F78AD55232F08583F3 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||||
|
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
AA01010A0000000011111111 /* Debug */ = {
|
AA01010A0000000011111111 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
||||||
@@ -1051,6 +1170,54 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
|
E5EE58583E83B7694F6C3C5E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||||
|
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
F418DF43742DC2E30B161652 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||||
|
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -1094,13 +1261,25 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E5EE58583E83B7694F6C3C5E /* Release */,
|
||||||
|
A2B046F78AD55232F08583F3 /* Debug */,
|
||||||
|
F418DF43742DC2E30B161652 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/app-check.git",
|
"location" : "https://github.com/google/app-check.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
||||||
"version" : "11.2.0"
|
"version" : "11.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
|
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||||
"version" : "12.13.0"
|
"version" : "12.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
|
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
||||||
"version" : "3.5.0"
|
"version" : "3.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
|
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||||
"version" : "12.13.0"
|
"version" : "12.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||||
"version" : "8.1.0"
|
"version" : "8.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/leveldb.git",
|
"location" : "https://github.com/firebase/leveldb.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||||
"version" : "1.22.2"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,8 +131,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/nanopb.git",
|
"location" : "https://github.com/firebase/nanopb.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||||
"version" : "2.30910.0"
|
"version" : "2.30910.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/promises.git",
|
"location" : "https://github.com/google/promises.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
"version" : "2.4.0"
|
"version" : "2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import workmanager_apple
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
@@ -12,7 +13,7 @@ import UserNotifications
|
|||||||
private let markReadActionId = "TALK_MARK_READ"
|
private let markReadActionId = "TALK_MARK_READ"
|
||||||
|
|
||||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
||||||
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||||
private let usernameAccount = "nextcloud_username"
|
private let usernameAccount = "nextcloud_username"
|
||||||
private let appPasswordAccount = "nextcloud_app_password"
|
private let appPasswordAccount = "nextcloud_app_password"
|
||||||
private let baseUrlAccount = "nextcloud_base_url"
|
private let baseUrlAccount = "nextcloud_base_url"
|
||||||
@@ -23,6 +24,19 @@ import UserNotifications
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
registerTalkCategory()
|
registerTalkCategory()
|
||||||
|
// BGAppRefresh for the home-screen widget. Must all happen before
|
||||||
|
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
|
||||||
|
// plugins only during scene connection, which is past BGTaskScheduler's
|
||||||
|
// registration deadline — registerLaunchHandlers() bridges that gap.
|
||||||
|
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
|
||||||
|
// plugin re-submits the refresh request itself after every run.
|
||||||
|
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||||
|
GeneratedPluginRegistrant.register(with: registry)
|
||||||
|
}
|
||||||
|
WorkmanagerPlugin.registerPeriodicTask(
|
||||||
|
withIdentifier: "eu.mhsl.marianum.widget.refresh",
|
||||||
|
earliestBeginInSeconds: 1800)
|
||||||
|
WorkmanagerPlugin.registerLaunchHandlers()
|
||||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||||
// forwards these callbacks to the plugins (firebase_messaging,
|
// forwards these callbacks to the plugins (firebase_messaging,
|
||||||
// flutter_local_notifications). We route Talk actions natively here — the
|
// flutter_local_notifications). We route Talk actions natively here — the
|
||||||
|
|||||||
@@ -66,6 +66,10 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
) {
|
) {
|
||||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
for context in connectionOptions.urlContexts {
|
for context in connectionOptions.urlContexts {
|
||||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||||
UIApplication.shared,
|
UIApplication.shared,
|
||||||
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
||||||
)
|
)
|
||||||
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
|
|
||||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
for context in URLContexts {
|
for context in URLContexts {
|
||||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||||
UIApplication.shared,
|
UIApplication.shared,
|
||||||
open: context.url,
|
open: context.url,
|
||||||
options: [:]
|
options: [:]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
// Datenmodell muss byte-für-byte zu dem passen, was
|
// Datenmodell muss byte-für-byte zu dem passen, was
|
||||||
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||||
private enum SharedMediaType: String, Codable {
|
private enum SharedMediaType: String, Codable {
|
||||||
case image, video, text, file, url
|
case image, video, text, file, url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart`
|
||||||
|
/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync — the Dart
|
||||||
|
/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the
|
||||||
|
/// review checklist here:
|
||||||
|
/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff)
|
||||||
|
/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff)
|
||||||
|
/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri → Mon)
|
||||||
|
/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11
|
||||||
|
/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11
|
||||||
|
/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04
|
||||||
|
/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11
|
||||||
|
enum TimetableAnchor {
|
||||||
|
/// After 17:00 the user's question shifts from "what's left today" to
|
||||||
|
/// "what's tomorrow", so the day widget rolls forward.
|
||||||
|
static let dayCutoffHour = 17
|
||||||
|
|
||||||
|
static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||||
|
var candidate = calendar.startOfDay(for: now)
|
||||||
|
let shiftToTomorrow =
|
||||||
|
calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar)
|
||||||
|
if shiftToTomorrow {
|
||||||
|
candidate = nextDay(candidate, calendar: calendar)
|
||||||
|
}
|
||||||
|
while isWeekend(candidate, calendar: calendar) {
|
||||||
|
candidate = nextDay(candidate, calendar: calendar)
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||||
|
let anchor = resolveDayAnchor(now, calendar: calendar)
|
||||||
|
// Swift weekday: 1 = Sunday … 7 = Saturday → distance back to Monday.
|
||||||
|
let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7
|
||||||
|
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor
|
||||||
|
return calendar.startOfDay(for: monday)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Future instants at which the rendered anchor can change: each day's
|
||||||
|
/// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted,
|
||||||
|
/// strictly after `now`.
|
||||||
|
static func boundaryDates(
|
||||||
|
from now: Date,
|
||||||
|
horizonDays: Int = 3,
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) -> [Date] {
|
||||||
|
var result: [Date] = []
|
||||||
|
let today = calendar.startOfDay(for: now)
|
||||||
|
for offset in 0...horizonDays {
|
||||||
|
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
|
||||||
|
let midnight = calendar.startOfDay(for: day)
|
||||||
|
if midnight > now { result.append(midnight) }
|
||||||
|
if let cutoff = calendar.date(
|
||||||
|
bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day
|
||||||
|
), cutoff > now {
|
||||||
|
result.append(cutoff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives a day payload from the 14-day week payload — same shape the
|
||||||
|
/// Dart buildDayData produces, since the week payload runs through the
|
||||||
|
/// identical per-day merge/collision pipeline.
|
||||||
|
static func slice(
|
||||||
|
week: WidgetTimetableData,
|
||||||
|
forDay anchor: Date,
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) -> WidgetTimetableData {
|
||||||
|
let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) }
|
||||||
|
// Every v2 week payload carries `days` — the key bump guarantees it.
|
||||||
|
let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) }
|
||||||
|
return WidgetTimetableData(
|
||||||
|
fetchedAt: week.fetchedAt,
|
||||||
|
anchorDate: anchor,
|
||||||
|
lessons: lessons,
|
||||||
|
periods: week.periods,
|
||||||
|
isHoliday: dayInfo?.isHoliday ?? false,
|
||||||
|
holidayName: dayInfo?.holidayName,
|
||||||
|
days: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Week payload re-anchored to the week containing/following `anchorDate`.
|
||||||
|
/// The week view filters its five columns off `anchorDate`, so this alone
|
||||||
|
/// performs the Friday-evening/weekend jump to next week.
|
||||||
|
static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData {
|
||||||
|
WidgetTimetableData(
|
||||||
|
fetchedAt: week.fetchedAt,
|
||||||
|
anchorDate: anchorDate,
|
||||||
|
lessons: week.lessons,
|
||||||
|
periods: week.periods,
|
||||||
|
isHoliday: week.isHoliday,
|
||||||
|
holidayName: week.holidayName,
|
||||||
|
days: week.days
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool {
|
||||||
|
let weekday = calendar.component(.weekday, from: date)
|
||||||
|
return weekday == 1 || weekday == 7
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextDay(_ date: Date, calendar: Calendar) -> Date {
|
||||||
|
let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date
|
||||||
|
return calendar.startOfDay(for: next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,13 +100,13 @@ struct TimetableDayView: View {
|
|||||||
|
|
||||||
private func header(data: WidgetTimetableData) -> some View {
|
private func header(data: WidgetTimetableData) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(dayLabel(for: data.anchorDate))
|
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -383,6 +383,7 @@ struct TimeGridView: View {
|
|||||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
||||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
||||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
||||||
|
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,9 +401,31 @@ func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
|||||||
return result.sorted()
|
return result.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
func dayLabel(for date: Date) -> String {
|
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||||
|
/// expensive part and the multi-entry timelines render up to ~8 entries per
|
||||||
|
/// reload. Only touched from WidgetKit's archival rendering, so the shared
|
||||||
|
/// instances are safe.
|
||||||
|
enum WidgetDateFormatters {
|
||||||
|
static let shortDate = make("dd.MM.")
|
||||||
|
static let weekdayShort = make("EE")
|
||||||
|
static let weekdayDate = make("EEEE · dd.MM.")
|
||||||
|
static let time = make("HH:mm")
|
||||||
|
static let dateTime = make("dd.MM. HH:mm")
|
||||||
|
|
||||||
|
private static func make(_ format: String) -> DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "de_DE")
|
||||||
|
f.dateFormat = format
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
|
||||||
|
/// entries ahead of time, so wall-clock reads would be wrong for every
|
||||||
|
/// entry after the first.
|
||||||
|
func dayLabel(for date: Date, relativeTo now: Date) -> String {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: now)
|
||||||
let anchor = cal.startOfDay(for: date)
|
let anchor = cal.startOfDay(for: date)
|
||||||
if anchor == today {
|
if anchor == today {
|
||||||
return "Heute · \(shortDate(date))"
|
return "Heute · \(shortDate(date))"
|
||||||
@@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String {
|
|||||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||||
return "Morgen · \(shortDate(date))"
|
return "Morgen · \(shortDate(date))"
|
||||||
}
|
}
|
||||||
let formatter = DateFormatter()
|
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||||
formatter.locale = Locale(identifier: "de_DE")
|
|
||||||
formatter.dateFormat = "EEEE · dd.MM."
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortDate(_ date: Date) -> String {
|
func shortDate(_ date: Date) -> String {
|
||||||
let f = DateFormatter()
|
WidgetDateFormatters.shortDate.string(from: date)
|
||||||
f.locale = Locale(identifier: "de_DE")
|
|
||||||
f.dateFormat = "dd.MM."
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: now)
|
||||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||||
let timeFmt = DateFormatter()
|
|
||||||
timeFmt.locale = Locale(identifier: "de_DE")
|
|
||||||
timeFmt.dateFormat = "HH:mm"
|
|
||||||
if fetchedDay == today {
|
if fetchedDay == today {
|
||||||
return timeFmt.string(from: fetchedAt)
|
return WidgetDateFormatters.time.string(from: fetchedAt)
|
||||||
}
|
}
|
||||||
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||||
fetchedDay == yesterday {
|
fetchedDay == yesterday {
|
||||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||||
}
|
}
|
||||||
let dateTimeFmt = DateFormatter()
|
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
|
||||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
|
||||||
return dateTimeFmt.string(from: fetchedAt)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.8)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func weekday(for date: Date) -> String {
|
private func weekday(for date: Date) -> String {
|
||||||
let f = DateFormatter()
|
WidgetDateFormatters.weekdayShort.string(from: date)
|
||||||
f.locale = Locale(identifier: "de_DE")
|
|
||||||
f.dateFormat = "EE"
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func placeholder(_ message: String) -> some View {
|
private func placeholder(_ message: String) -> some View {
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
|||||||
in context: Context,
|
in context: Context,
|
||||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||||
) {
|
) {
|
||||||
let entry = TimetableEntry.current(variant: .day)
|
let now = Date()
|
||||||
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
||||||
// advisory; the "Stand:" label tells the user when data is stale.
|
// advisory; the boundary entries below keep the rendered day correct
|
||||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
// even when no reload is granted, and the "Stand:" label tells the
|
||||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
// user when the underlying data is stale.
|
||||||
|
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
|
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
|
|||||||
in context: Context,
|
in context: Context,
|
||||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||||
) {
|
) {
|
||||||
let entry = TimetableEntry.current(variant: .week)
|
let now = Date()
|
||||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
|||||||
themeMode: WidgetDataLoader.themeMode()
|
themeMode: WidgetDataLoader.themeMode()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Day timeline derived from the 14-day week payload, so the widget shows
|
||||||
|
/// the right day even when iOS grants no reload for days.
|
||||||
|
static func dayEntries(now: Date) -> [TimetableEntry] {
|
||||||
|
entries(now: now, variant: .day) { week, date in
|
||||||
|
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
|
||||||
|
/// into next week from cached data, and the midnight entries keep the
|
||||||
|
/// "Stand:" freshness label honest.
|
||||||
|
static func weekEntries(now: Date) -> [TimetableEntry] {
|
||||||
|
entries(now: now, variant: .week) { week, date in
|
||||||
|
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared timeline skeleton: one entry now plus one per anchor boundary
|
||||||
|
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
|
||||||
|
/// render — same anchor and same calendar day for the header labels —
|
||||||
|
/// are dropped.
|
||||||
|
private static func entries(
|
||||||
|
now: Date,
|
||||||
|
variant: TimetableVariant,
|
||||||
|
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
|
||||||
|
) -> [TimetableEntry] {
|
||||||
|
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
|
||||||
|
// Logged out, or no v2 week snapshot yet (fresh app update):
|
||||||
|
// fall back to the legacy single-entry payload.
|
||||||
|
return [TimetableEntry.current(variant: variant)]
|
||||||
|
}
|
||||||
|
let theme = WidgetDataLoader.themeMode()
|
||||||
|
let cal = Calendar.current
|
||||||
|
var result: [TimetableEntry] = []
|
||||||
|
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
|
||||||
|
let data = transform(week, date)
|
||||||
|
if let previous = result.last, let previousData = previous.data,
|
||||||
|
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
|
||||||
|
cal.isDate(previous.date, inSameDayAs: date) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.append(TimetableEntry(
|
||||||
|
date: date,
|
||||||
|
variant: variant,
|
||||||
|
data: data,
|
||||||
|
isLoggedIn: true,
|
||||||
|
themeMode: theme
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
|||||||
case irregular
|
case irregular
|
||||||
case teacherChanged
|
case teacherChanged
|
||||||
case event
|
case event
|
||||||
|
case duty
|
||||||
|
|
||||||
|
/// Unknown future statuses degrade to `.regular` instead of failing the
|
||||||
|
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
|
||||||
|
/// single new enum value must never blank the widget to the placeholder.
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
self = WidgetLessonStatus(rawValue: raw) ?? .regular
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WidgetLesson: Codable {
|
struct WidgetLesson: Codable {
|
||||||
@@ -18,6 +27,8 @@ struct WidgetLesson: Codable {
|
|||||||
let subjectShort: String
|
let subjectShort: String
|
||||||
let subjectLong: String?
|
let subjectLong: String?
|
||||||
let room: String?
|
let room: String?
|
||||||
|
// On teacher accounts this carries the class label ("7a") instead of the
|
||||||
|
// teacher short name (originalTeacher is nil then) — mapped in Dart.
|
||||||
let teacher: String?
|
let teacher: String?
|
||||||
let originalTeacher: String?
|
let originalTeacher: String?
|
||||||
let status: WidgetLessonStatus
|
let status: WidgetLessonStatus
|
||||||
@@ -33,6 +44,12 @@ struct WidgetPeriod: Codable {
|
|||||||
let virtualEndMinutes: Int
|
let virtualEndMinutes: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WidgetDayInfo: Codable {
|
||||||
|
let date: Date
|
||||||
|
let isHoliday: Bool
|
||||||
|
let holidayName: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct WidgetTimetableData: Codable {
|
struct WidgetTimetableData: Codable {
|
||||||
let fetchedAt: Date
|
let fetchedAt: Date
|
||||||
let anchorDate: Date
|
let anchorDate: Date
|
||||||
@@ -40,12 +57,17 @@ struct WidgetTimetableData: Codable {
|
|||||||
let periods: [WidgetPeriod]
|
let periods: [WidgetPeriod]
|
||||||
let isHoliday: Bool
|
let isHoliday: Bool
|
||||||
let holidayName: String?
|
let holidayName: String?
|
||||||
|
/// Week payload (v2) only; optional so day payloads keep decoding.
|
||||||
|
let days: [WidgetDayInfo]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||||
|
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
|
||||||
|
/// together, or the out-of-sync platform silently blanks to the placeholder.
|
||||||
enum WidgetDataKey {
|
enum WidgetDataKey {
|
||||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||||
static let dayData = "widget_data_day_v1"
|
static let dayData = "widget_data_day_v1"
|
||||||
static let weekData = "widget_data_week_v1"
|
static let weekData = "widget_data_week_v2"
|
||||||
static let loggedIn = "widget_data_logged_in_v1"
|
static let loggedIn = "widget_data_logged_in_v1"
|
||||||
static let themeMode = "widget_setting_theme_mode_v1"
|
static let themeMode = "widget_setting_theme_mode_v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
class ApiRequest {}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||||
|
|
||||||
|
/// Demo fixtures for the absence-report form: the class dropdown and the
|
||||||
|
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
|
||||||
|
/// silently drift the demo shape away from what the queries parse.
|
||||||
|
class DemoAbsence {
|
||||||
|
const DemoAbsence._();
|
||||||
|
|
||||||
|
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
|
||||||
|
|
||||||
|
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
className: '10a',
|
||||||
|
phone: '0123 456789',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,19 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
|||||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||||
|
|
||||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||||
/// demo persona sees every feature (incl. push) as available.
|
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||||
|
/// scroll range stays unlimited (null day counts).
|
||||||
class DemoCapabilities {
|
class DemoCapabilities {
|
||||||
const DemoCapabilities._();
|
const DemoCapabilities._();
|
||||||
|
|
||||||
static CapabilitiesState state() => const CapabilitiesState(
|
static CapabilitiesState state() => const CapabilitiesState(
|
||||||
viewForeignTimetables: true,
|
viewForeignTimetables: true,
|
||||||
pushNotifications: true,
|
pushNotifications: true,
|
||||||
|
timetablePastDays: null,
|
||||||
|
timetableFutureDays: null,
|
||||||
|
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
|
||||||
|
// Feld nicht" (siehe CapabilitiesResponse.userType).
|
||||||
|
userType: 'STUDENT',
|
||||||
loaded: true,
|
loaded: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.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
|
/// Demo fixtures for the info messages — a single friendly welcome entry so the
|
||||||
@@ -6,16 +8,16 @@ class DemoMarianumMessage {
|
|||||||
const DemoMarianumMessage._();
|
const DemoMarianumMessage._();
|
||||||
|
|
||||||
static MarianumMessageList list() {
|
static MarianumMessageList list() {
|
||||||
final today = DateTime.now();
|
final date = DateFormat.yMMMM('de').format(DateTime.now());
|
||||||
final date =
|
|
||||||
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
|
|
||||||
|
|
||||||
return MarianumMessageList(
|
return MarianumMessageList(
|
||||||
base: 'https://marianum-fulda.de',
|
base: 'https://marianum-fulda.de',
|
||||||
messages: [
|
messages: [
|
||||||
MarianumMessage(
|
MarianumMessage(
|
||||||
|
id: 'demo',
|
||||||
name: 'Willkommen in der Marianum-App',
|
name: 'Willkommen in der Marianum-App',
|
||||||
date: date,
|
date: date,
|
||||||
|
description: 'Eine kurze Begrüßung zum Ausprobieren.',
|
||||||
url: 'https://marianum-fulda.de',
|
url: 'https://marianum-fulda.de',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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,6 +1,8 @@
|
|||||||
|
import 'data/demo_absence.dart';
|
||||||
import 'data/demo_breaker.dart';
|
import 'data/demo_breaker.dart';
|
||||||
import 'data/demo_holidays.dart';
|
import 'data/demo_holidays.dart';
|
||||||
import 'data/demo_timetable.dart';
|
import 'data/demo_timetable.dart';
|
||||||
|
import 'data/demo_users.dart';
|
||||||
|
|
||||||
/// Single source of truth for the MarianumConnect demo responses. The demo
|
/// Single source of truth for the MarianumConnect demo responses. The demo
|
||||||
/// interceptor asks this for the body of any MC request. Read endpoints reuse
|
/// interceptor asks this for the body of any MC request. Read endpoints reuse
|
||||||
@@ -32,8 +34,16 @@ class DemoMarianumConnect {
|
|||||||
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||||
case 'holidays':
|
case 'holidays':
|
||||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||||
|
case 'users/search':
|
||||||
|
return DemoUsers.search(query['q']?.toString() ?? '')
|
||||||
|
.map((e) => e.toJson())
|
||||||
|
.toList();
|
||||||
case 'breaker':
|
case 'breaker':
|
||||||
return DemoBreaker.none().toJson();
|
return DemoBreaker.none().toJson();
|
||||||
|
case 'absence/classes':
|
||||||
|
return DemoAbsence.classes();
|
||||||
|
case 'absence/prefill':
|
||||||
|
return DemoAbsence.prefill().toJson();
|
||||||
case 'timetable/elements/teachers':
|
case 'timetable/elements/teachers':
|
||||||
case 'timetable/elements/students':
|
case 'timetable/elements/students':
|
||||||
case 'timetable/elements/classes':
|
case 'timetable/elements/classes':
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/// A backend-independent emergency notice loaded from a foreign server URL —
|
||||||
|
/// frontmatter (control fields) plus a free Markdown body, so it stays
|
||||||
|
/// hand-writable in an outage. See [parse] for the format.
|
||||||
|
class EmergencyNotice {
|
||||||
|
/// `false` renders full-screen and blocks back/barrier taps.
|
||||||
|
final bool dismissible;
|
||||||
|
final String? title;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
const EmergencyNotice({
|
||||||
|
required this.dismissible,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Parses the raw file, or returns `null` when there is nothing to show.
|
||||||
|
/// Never throws — malformed input yields `null` so a broken file can't break
|
||||||
|
/// the app.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// ---
|
||||||
|
/// active: true # required truthy, else null; # lines are comments
|
||||||
|
/// dismissible: true # default true
|
||||||
|
/// title: Störung # optional
|
||||||
|
/// ---
|
||||||
|
/// Free **markdown** body (everything after the closing ---).
|
||||||
|
/// ```
|
||||||
|
static EmergencyNotice? parse(String raw) {
|
||||||
|
final lines = raw
|
||||||
|
.replaceAll('\r\n', '\n')
|
||||||
|
.replaceAll('\r', '\n')
|
||||||
|
.split('\n');
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.length && lines[i].trim().isEmpty) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i >= lines.length || lines[i].trim() != '---') return null;
|
||||||
|
final openIndex = i;
|
||||||
|
|
||||||
|
var closeIndex = -1;
|
||||||
|
for (var j = openIndex + 1; j < lines.length; j++) {
|
||||||
|
if (lines[j].trim() == '---') {
|
||||||
|
closeIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closeIndex == -1) return null;
|
||||||
|
|
||||||
|
final meta = <String, String>{};
|
||||||
|
for (var j = openIndex + 1; j < closeIndex; j++) {
|
||||||
|
final line = lines[j].trim();
|
||||||
|
if (line.isEmpty || line.startsWith('#')) continue;
|
||||||
|
final sep = line.indexOf(':');
|
||||||
|
if (sep <= 0) continue;
|
||||||
|
final key = line.substring(0, sep).trim().toLowerCase();
|
||||||
|
final value = line.substring(sep + 1).trim();
|
||||||
|
meta[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_parseBool(meta['active']) != true) return null;
|
||||||
|
|
||||||
|
final body = lines.sublist(closeIndex + 1).join('\n').trim();
|
||||||
|
if (body.isEmpty) return null;
|
||||||
|
|
||||||
|
final title = meta['title'];
|
||||||
|
return EmergencyNotice(
|
||||||
|
dismissible: _parseBool(meta['dismissible']) ?? true,
|
||||||
|
title: (title == null || title.isEmpty) ? null : title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool? _parseBool(String? value) {
|
||||||
|
switch (value?.trim().toLowerCase()) {
|
||||||
|
case 'true':
|
||||||
|
case 'yes':
|
||||||
|
case '1':
|
||||||
|
case 'on':
|
||||||
|
return true;
|
||||||
|
case 'false':
|
||||||
|
case 'no':
|
||||||
|
case '0':
|
||||||
|
case 'off':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'emergency_notice.dart';
|
||||||
|
|
||||||
|
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
|
||||||
|
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
|
||||||
|
/// Never throws: any failure yields `null` (nothing shown).
|
||||||
|
///
|
||||||
|
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
|
||||||
|
/// it lives only for the process, so a cold start always fetches fresh.
|
||||||
|
class EmergencyNoticeClient {
|
||||||
|
EmergencyNoticeClient();
|
||||||
|
|
||||||
|
static const Duration cacheTtl = Duration(minutes: 1);
|
||||||
|
|
||||||
|
EmergencyNotice? _cached;
|
||||||
|
String? _cachedUrl;
|
||||||
|
DateTime? _cachedAt;
|
||||||
|
|
||||||
|
Future<EmergencyNotice?> fetch(String url) async {
|
||||||
|
final cachedAt = _cachedAt;
|
||||||
|
if (cachedAt != null &&
|
||||||
|
_cachedUrl == url &&
|
||||||
|
DateTime.now().difference(cachedAt) < cacheTtl) {
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmergencyNotice? result;
|
||||||
|
try {
|
||||||
|
final dio = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
receiveTimeout: const Duration(seconds: 5),
|
||||||
|
sendTimeout: const Duration(seconds: 5),
|
||||||
|
responseType: ResponseType.plain,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final response = await dio.get<String>(url);
|
||||||
|
final raw = response.data;
|
||||||
|
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache failures too, so a down server isn't retried on every resume.
|
||||||
|
_cached = result;
|
||||||
|
_cachedUrl = url;
|
||||||
|
_cachedAt = DateTime.now();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
|
import '../http_errors.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
import 'app_exception.dart';
|
import 'app_exception.dart';
|
||||||
import 'auth_exception.dart';
|
import 'auth_exception.dart';
|
||||||
@@ -59,9 +60,8 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final preview = previewBody(error.body);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||||
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
return AuthException.unauthorized(technicalDetails: detail);
|
return AuthException.unauthorized(technicalDetails: detail);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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,53 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'errors/auth_exception.dart';
|
||||||
|
import 'errors/network_exception.dart';
|
||||||
|
import 'errors/not_found_exception.dart';
|
||||||
|
import 'errors/server_exception.dart';
|
||||||
|
|
||||||
|
/// Runs [send] and converts transport-level failures (socket/timeout/client
|
||||||
|
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
|
||||||
|
/// Passes through whatever [send] produces, including `null` for the base-class
|
||||||
|
/// request hooks that may skip the call.
|
||||||
|
Future<http.Response?> sendGuarded(
|
||||||
|
String label,
|
||||||
|
Future<http.Response>? Function() send,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await send();
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||||
|
} on TimeoutException catch (e) {
|
||||||
|
throw NetworkException.timeout(technicalDetails: '$label: $e');
|
||||||
|
} on http.ClientException catch (e) {
|
||||||
|
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
|
||||||
|
/// embedded in an [AppException]'s technical details without dumping headers.
|
||||||
|
String previewBody(String body) {
|
||||||
|
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}…' : collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
|
||||||
|
String httpErrorDetail(String label, String body, int status) {
|
||||||
|
final preview = previewBody(body);
|
||||||
|
return preview.isEmpty
|
||||||
|
? '$label -> HTTP $status'
|
||||||
|
: '$label -> HTTP $status body=$preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
|
||||||
|
/// [detail] as technical details: 401/403 map to auth errors, 404 to
|
||||||
|
/// not-found, everything else to a generic server error.
|
||||||
|
Never throwForStatus(int status, String detail) {
|
||||||
|
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||||
|
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||||
|
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||||
|
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../model/account_data.dart';
|
||||||
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||||
@@ -18,10 +19,14 @@ class GetAppPassword {
|
|||||||
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
/// Returns the freshly minted app password. Throws on any transport or
|
/// Returns the freshly minted app password. Throws on any transport or
|
||||||
/// protocol error — callers treat push registration as best-effort and swallow
|
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||||
/// failures.
|
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||||
|
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||||
Future<String> run() async {
|
Future<String> run() async {
|
||||||
final response = await _client.get(
|
const label = 'Nextcloud getapppassword';
|
||||||
|
final response = (await sendGuarded(
|
||||||
|
label,
|
||||||
|
() => _client.get(
|
||||||
NextcloudOcs.uri('core/getapppassword'),
|
NextcloudOcs.uri('core/getapppassword'),
|
||||||
headers: {
|
headers: {
|
||||||
...NextcloudOcs.headers(),
|
...NextcloudOcs.headers(),
|
||||||
@@ -30,9 +35,13 @@ class GetAppPassword {
|
|||||||
// this endpoint requires the real password.
|
// this endpoint requires the real password.
|
||||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
|
))!;
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
throwForStatus(
|
||||||
|
response.statusCode,
|
||||||
|
httpErrorDetail(label, response.body, response.statusCode),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
final data = (json as Map)['ocs']?['data'];
|
final data = (json as Map)['ocs']?['data'];
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@@ -38,9 +37,6 @@ class AutocompleteApi {
|
|||||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
||||||
return AutocompleteResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../model/account_data.dart';
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
|
||||||
import '../../errors/network_exception.dart';
|
|
||||||
import '../../errors/not_found_exception.dart';
|
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Mix of two Nextcloud surfaces:
|
/// Mix of two Nextcloud surfaces:
|
||||||
@@ -42,30 +37,17 @@ Future<http.Response> _send(
|
|||||||
) async {
|
) async {
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
final http.Response response;
|
final response = (await sendGuarded(
|
||||||
try {
|
'Cloud $uri',
|
||||||
response = await perform(uri, headers);
|
() => perform(uri, headers),
|
||||||
} on SocketException catch (e) {
|
))!;
|
||||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final status = response.statusCode;
|
final status = response.statusCode;
|
||||||
if (status >= 200 && status < 300) return response;
|
if (status >= 200 && status < 300) return response;
|
||||||
|
|
||||||
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final detail = httpErrorDetail('Cloud $uri', response.body, status);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
|
||||||
final detail = body.isEmpty
|
|
||||||
? 'Cloud $uri -> HTTP $status'
|
|
||||||
: 'Cloud $uri -> HTTP $status body=$preview';
|
|
||||||
log(detail);
|
log(detail);
|
||||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
throwForStatus(status, detail);
|
||||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
|
||||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
|
||||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SetUserAvatar {
|
class SetUserAvatar {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../http_errors.dart';
|
||||||
|
import '../../marianumconnect/auth/device_token_name.dart';
|
||||||
|
|
||||||
|
/// Nextcloud Login Flow v2 (`/index.php/login/v2`): interactive browser login
|
||||||
|
/// that yields an app password. It is the only way to obtain working Nextcloud
|
||||||
|
/// credentials when the account is protected by two-factor authentication —
|
||||||
|
/// Basic auth with the real password is rejected server-side in that case.
|
||||||
|
class LoginFlowApi {
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
LoginFlowApi({http.Client? client}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
|
/// Starts a new flow. Nextcloud displays the request's User-Agent as the
|
||||||
|
/// token name in the user's security settings, so the device token label is
|
||||||
|
/// sent (`"Marianum Fulda App (Pixel 10)"`).
|
||||||
|
Future<LoginFlowInit> start() async {
|
||||||
|
final userAgent = await DeviceTokenName.resolve();
|
||||||
|
final uri = _initUri();
|
||||||
|
const label = 'Nextcloud login flow init';
|
||||||
|
final response = (await sendGuarded(
|
||||||
|
label,
|
||||||
|
() => _client.post(
|
||||||
|
uri,
|
||||||
|
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
|
||||||
|
),
|
||||||
|
))!;
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throwForStatus(
|
||||||
|
response.statusCode,
|
||||||
|
httpErrorDetail(label, response.body, response.statusCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return LoginFlowInit.fromJson(
|
||||||
|
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls for the flow result: `null` while the browser login has not been
|
||||||
|
/// completed yet (HTTP 404), the final credentials once it has.
|
||||||
|
Future<LoginFlowCredentials?> poll(LoginFlowInit flow) async {
|
||||||
|
const label = 'Nextcloud login flow poll';
|
||||||
|
final response = (await sendGuarded(
|
||||||
|
label,
|
||||||
|
() => _client.post(
|
||||||
|
Uri.parse(flow.pollEndpoint),
|
||||||
|
headers: {'Accept': 'application/json'},
|
||||||
|
body: {'token': flow.pollToken},
|
||||||
|
),
|
||||||
|
))!;
|
||||||
|
if (response.statusCode == 404) return null;
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throwForStatus(
|
||||||
|
response.statusCode,
|
||||||
|
httpErrorDetail(label, response.body, response.statusCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return LoginFlowCredentials.fromJson(
|
||||||
|
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri _initUri() {
|
||||||
|
final endpoint = EndpointData().nextcloud();
|
||||||
|
return Uri.https(endpoint.domain, '${endpoint.path}/index.php/login/v2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the login name reported by the completed flow belongs to the
|
||||||
|
/// account this app session expects — the browser login could have been
|
||||||
|
/// completed with a different Nextcloud account.
|
||||||
|
static bool loginNameMatches({
|
||||||
|
required String expected,
|
||||||
|
required String actual,
|
||||||
|
}) => actual.trim().toLowerCase() == expected.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response of the flow init call: the URL the user opens in the browser plus
|
||||||
|
/// the token/endpoint pair the app polls until the login is confirmed.
|
||||||
|
class LoginFlowInit {
|
||||||
|
final String loginUrl;
|
||||||
|
final String pollToken;
|
||||||
|
final String pollEndpoint;
|
||||||
|
|
||||||
|
const LoginFlowInit({
|
||||||
|
required this.loginUrl,
|
||||||
|
required this.pollToken,
|
||||||
|
required this.pollEndpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginFlowInit.fromJson(Map<String, dynamic> json) {
|
||||||
|
final poll = json['poll'];
|
||||||
|
final loginUrl = json['login'] as String?;
|
||||||
|
final token = poll is Map ? poll['token'] as String? : null;
|
||||||
|
final endpoint = poll is Map ? poll['endpoint'] as String? : null;
|
||||||
|
if (loginUrl == null || loginUrl.isEmpty) {
|
||||||
|
throw const FormatException('login flow init: missing login url');
|
||||||
|
}
|
||||||
|
if (token == null || token.isEmpty || endpoint == null || endpoint.isEmpty) {
|
||||||
|
throw const FormatException('login flow init: missing poll token/endpoint');
|
||||||
|
}
|
||||||
|
return LoginFlowInit(
|
||||||
|
loginUrl: loginUrl,
|
||||||
|
pollToken: token,
|
||||||
|
pollEndpoint: endpoint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials returned once the user confirmed the login in the browser.
|
||||||
|
class LoginFlowCredentials {
|
||||||
|
final String server;
|
||||||
|
final String loginName;
|
||||||
|
final String appPassword;
|
||||||
|
|
||||||
|
const LoginFlowCredentials({
|
||||||
|
required this.server,
|
||||||
|
required this.loginName,
|
||||||
|
required this.appPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LoginFlowCredentials.fromJson(Map<String, dynamic> json) {
|
||||||
|
final loginName = json['loginName'] as String?;
|
||||||
|
final appPassword = json['appPassword'] as String?;
|
||||||
|
if (loginName == null || loginName.isEmpty) {
|
||||||
|
throw const FormatException('login flow poll: missing loginName');
|
||||||
|
}
|
||||||
|
if (appPassword == null || appPassword.isEmpty) {
|
||||||
|
throw const FormatException('login flow poll: missing appPassword');
|
||||||
|
}
|
||||||
|
return LoginFlowCredentials(
|
||||||
|
server: json['server'] as String? ?? '',
|
||||||
|
loginName: loginName,
|
||||||
|
appPassword: appPassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
import '../../model/account_data.dart';
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
|
||||||
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
|
|||||||
class NextcloudOcs {
|
class NextcloudOcs {
|
||||||
NextcloudOcs._();
|
NextcloudOcs._();
|
||||||
|
|
||||||
|
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
|
||||||
|
/// every response nests its `meta`/`data` under).
|
||||||
|
static Map<String, dynamic> decode(String raw) =>
|
||||||
|
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
||||||
|
|
||||||
static Map<String, String> headers() => {
|
static Map<String, String> headers() => {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@@ -28,9 +27,7 @@ class SearchFiles {
|
|||||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
|
||||||
final data = ocs['data'] as Map<String, dynamic>;
|
|
||||||
return SearchFilesResponse.fromJson(data);
|
return SearchFilesResponse.fromJson(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_chat_params.dart';
|
import 'get_chat_params.dart';
|
||||||
import 'get_chat_response.dart';
|
import 'get_chat_response.dart';
|
||||||
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
|
|||||||
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetChatResponse assemble(String raw) {
|
GetChatResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response> request(
|
Future<Response> request(
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
|
|||||||
GetChatParams(
|
GetChatParams(
|
||||||
lookIntoFuture: GetChatParamsSwitch.off,
|
lookIntoFuture: GetChatParamsSwitch.off,
|
||||||
setReadMarker: GetChatParamsSwitch.on,
|
setReadMarker: GetChatParamsSwitch.on,
|
||||||
limit: 200,
|
// Small initial page; also the per-chat offline snapshot written to
|
||||||
|
// localstore. Older messages are paged in on scroll-up via
|
||||||
|
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
||||||
|
limit: 50,
|
||||||
),
|
),
|
||||||
).run(),
|
).run(),
|
||||||
fromJson: GetChatResponse.fromJson,
|
fromJson: GetChatResponse.fromJson,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../errors/server_exception.dart';
|
||||||
|
import '../../../http_errors.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import 'get_chat_params.dart';
|
||||||
|
import 'get_chat_response.dart';
|
||||||
|
|
||||||
|
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
||||||
|
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
|
||||||
|
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
|
||||||
|
/// when there are no older messages left — a normal "start of chat" outcome here.
|
||||||
|
/// `setReadMarker=off` so paging into history never moves the read cursor.
|
||||||
|
class GetChatHistory {
|
||||||
|
final String chatToken;
|
||||||
|
final int lastKnownMessageId;
|
||||||
|
final int limit;
|
||||||
|
|
||||||
|
GetChatHistory({
|
||||||
|
required this.chatToken,
|
||||||
|
required this.lastKnownMessageId,
|
||||||
|
required this.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
||||||
|
Future<GetChatResponse?> run() async {
|
||||||
|
final params = GetChatParams(
|
||||||
|
lookIntoFuture: GetChatParamsSwitch.off,
|
||||||
|
lastKnownMessageId: lastKnownMessageId,
|
||||||
|
includeLastKnown: GetChatParamsSwitch.off,
|
||||||
|
setReadMarker: GetChatParamsSwitch.off,
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
final uri = NextcloudOcs.uri(
|
||||||
|
'apps/spreed/api/v1/chat/$chatToken',
|
||||||
|
queryParameters: params.toJson(),
|
||||||
|
);
|
||||||
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
|
final response = (await sendGuarded(
|
||||||
|
'GetChatHistory $uri',
|
||||||
|
() => http.get(uri, headers: headers),
|
||||||
|
))!;
|
||||||
|
|
||||||
|
final status = response.statusCode;
|
||||||
|
if (status == 304) return null;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||||
|
..headers = response.headers;
|
||||||
|
}
|
||||||
|
throw ServerException(
|
||||||
|
statusCode: status,
|
||||||
|
technicalDetails: 'GetChatHistory $uri: HTTP $status',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../errors/network_exception.dart';
|
|
||||||
import '../../../errors/server_exception.dart';
|
import '../../../errors/server_exception.dart';
|
||||||
|
import '../../../http_errors.dart';
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import 'get_chat_params.dart';
|
import 'get_chat_params.dart';
|
||||||
import 'get_chat_response.dart';
|
import 'get_chat_response.dart';
|
||||||
@@ -41,24 +37,17 @@ class LongPollChat {
|
|||||||
);
|
);
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
final http.Response response;
|
final response = (await sendGuarded(
|
||||||
try {
|
'LongPollChat $uri',
|
||||||
response = await http
|
() => http
|
||||||
.get(uri, headers: headers)
|
.get(uri, headers: headers)
|
||||||
.timeout(Duration(seconds: timeoutSeconds + 15));
|
.timeout(Duration(seconds: timeoutSeconds + 15)),
|
||||||
} on TimeoutException catch (e) {
|
))!;
|
||||||
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final status = response.statusCode;
|
final status = response.statusCode;
|
||||||
if (status == 304) return null;
|
if (status == 304) return null;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
|
|
||||||
..headers = response.headers;
|
..headers = response.headers;
|
||||||
}
|
}
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../api_params.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import '../get_poll/get_poll_state_response.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
|
||||||
|
/// Schließt eine Umfrage endgültig — nur Ersteller oder Moderatoren.
|
||||||
|
class ClosePoll extends TalkApi<GetPollStateResponse> {
|
||||||
|
ClosePoll({required String token, required int pollId})
|
||||||
|
: super('v1/poll/$token/$pollId', null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetPollStateResponse assemble(String raw) =>
|
||||||
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
ApiParams? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.delete(uri, headers: headers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../api_params.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
import 'create_poll_params.dart';
|
||||||
|
|
||||||
|
/// Erstellt eine Umfrage; der Server postet die Poll-Nachricht selbst in den
|
||||||
|
/// Chat, danach genügt ein Chat-Refresh. Nur in Gruppen-Chats erlaubt.
|
||||||
|
class CreatePoll extends TalkApi {
|
||||||
|
CreatePoll({required String token, required CreatePollParams params})
|
||||||
|
: super(
|
||||||
|
'v1/poll/$token',
|
||||||
|
params,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Null assemble(String raw) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response>? request(
|
||||||
|
Uri uri,
|
||||||
|
ApiParams? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) {
|
||||||
|
if (body is! CreatePollParams) return null;
|
||||||
|
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../api_params.dart';
|
||||||
|
|
||||||
|
part 'create_poll_params.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class CreatePollParams extends ApiParams {
|
||||||
|
String question;
|
||||||
|
List<String> options;
|
||||||
|
|
||||||
|
/// 0 = Ergebnisse öffentlich, 1 = bis zum Schließen verborgen.
|
||||||
|
int resultMode;
|
||||||
|
|
||||||
|
/// Stimmen pro Teilnehmer; 0 = unbegrenzt.
|
||||||
|
int maxVotes;
|
||||||
|
|
||||||
|
CreatePollParams({
|
||||||
|
required this.question,
|
||||||
|
required this.options,
|
||||||
|
required this.resultMode,
|
||||||
|
required this.maxVotes,
|
||||||
|
});
|
||||||
|
factory CreatePollParams.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CreatePollParamsFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$CreatePollParamsToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'create_poll_params.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
|
||||||
|
CreatePollParams(
|
||||||
|
question: json['question'] as String,
|
||||||
|
options: (json['options'] as List<dynamic>)
|
||||||
|
.map((e) => e as String)
|
||||||
|
.toList(),
|
||||||
|
resultMode: (json['resultMode'] as num).toInt(),
|
||||||
|
maxVotes: (json['maxVotes'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'question': instance.question,
|
||||||
|
'options': instance.options,
|
||||||
|
'resultMode': instance.resultMode,
|
||||||
|
'maxVotes': instance.maxVotes,
|
||||||
|
};
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'create_room_params.dart';
|
import 'create_room_params.dart';
|
||||||
|
import 'create_room_response.dart';
|
||||||
|
|
||||||
class CreateRoom extends TalkApi {
|
class CreateRoom extends TalkApi<CreateRoomResponse> {
|
||||||
CreateRoomParams params;
|
CreateRoomParams params;
|
||||||
|
|
||||||
CreateRoom(this.params) : super('v4/room', params);
|
CreateRoom(this.params) : super('v4/room', params);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Null assemble(String raw) => null;
|
CreateRoomResponse assemble(String raw) =>
|
||||||
|
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response>? request(
|
Future<Response>? request(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../api_response.dart';
|
||||||
|
|
||||||
|
part 'create_room_response.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class CreateRoomResponse extends ApiResponse {
|
||||||
|
final CreateRoomResponseData data;
|
||||||
|
|
||||||
|
CreateRoomResponse(this.data);
|
||||||
|
|
||||||
|
factory CreateRoomResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CreateRoomResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class CreateRoomResponseData {
|
||||||
|
final String token;
|
||||||
|
|
||||||
|
CreateRoomResponseData(this.token);
|
||||||
|
|
||||||
|
factory CreateRoomResponseData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CreateRoomResponseDataFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$CreateRoomResponseDataToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'create_room_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
|
||||||
|
CreateRoomResponse(
|
||||||
|
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'data': instance.data.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => CreateRoomResponseData(json['token'] as String);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CreateRoomResponseDataToJson(
|
||||||
|
CreateRoomResponseData instance,
|
||||||
|
) => <String, dynamic>{'token': instance.token};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_participants_response.dart';
|
import 'get_participants_response.dart';
|
||||||
|
|
||||||
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
|
|||||||
GetParticipants(this.token) : super('v4/room/$token/participants', null);
|
GetParticipants(this.token) : super('v4/room/$token/participants', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetParticipantsResponse assemble(String raw) {
|
GetParticipantsResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetParticipantsResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_poll_state_response.dart';
|
import 'get_poll_state_response.dart';
|
||||||
|
|
||||||
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
|
|||||||
: super('v1/poll/$token/$pollId', null);
|
: super('v1/poll/$token/$pollId', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetPollStateResponse assemble(String raw) {
|
GetPollStateResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetPollStateResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import '../../../api_response.dart';
|
|||||||
|
|
||||||
part 'get_poll_state_response.g.dart';
|
part 'get_poll_state_response.g.dart';
|
||||||
|
|
||||||
|
/// Poll-`status`-Werte der Talk-API.
|
||||||
|
const int pollStatusOpen = 0;
|
||||||
|
const int pollStatusClosed = 1;
|
||||||
|
|
||||||
|
/// Poll-`resultMode`-Werte der Talk-API.
|
||||||
|
const int pollResultModePublic = 0;
|
||||||
|
const int pollResultModeHidden = 1;
|
||||||
|
|
||||||
|
/// `participantType`-Werte (aus dem Room), die eine Umfrage schließen dürfen:
|
||||||
|
/// Owner (1), Moderator (2) und Gast-Moderator (6).
|
||||||
|
const Set<int> pollModeratorParticipantTypes = {1, 2, 6};
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetPollStateResponse extends ApiResponse {
|
class GetPollStateResponse extends ApiResponse {
|
||||||
GetPollStateResponseObject data;
|
GetPollStateResponseObject data;
|
||||||
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
|
|||||||
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
|
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||||
_$GetPollStateResponseObjectFromJson(json);
|
_$GetPollStateResponseObjectFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
|
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
|
||||||
|
|
||||||
|
bool get isClosed => status == pollStatusClosed;
|
||||||
|
|
||||||
|
bool get resultsHidden => resultMode == pollResultModeHidden;
|
||||||
|
|
||||||
|
/// Ergebnisse sichtbar: öffentliche Umfragen jederzeit, verborgene erst nach
|
||||||
|
/// dem Schließen. Der Typ von `votes` taugt nicht als Signal (siehe unten).
|
||||||
|
bool get resultsVisible => resultMode == pollResultModePublic || isClosed;
|
||||||
|
|
||||||
|
/// Normalisiert das dynamische `votes`-Feld zu einer Map: der Server liefert
|
||||||
|
/// bei verborgenen Ergebnissen (und ohne Stimmen) eine leere Liste statt Map.
|
||||||
|
Map<String, num> get voteCounts {
|
||||||
|
final raw = votes;
|
||||||
|
if (raw is! Map) return const {};
|
||||||
|
final result = <String, num>{};
|
||||||
|
raw.forEach((key, value) {
|
||||||
|
if (key is String && value is num) result[key] = value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Darf der Nutzer die (offene) Umfrage schließen: als Ersteller oder Moderator.
|
||||||
|
bool canClose({required String selfId, required int participantType}) {
|
||||||
|
if (isClosed) return false;
|
||||||
|
final isCreator = actorType == 'users' && actorId == selfId;
|
||||||
|
final isModerator = pollModeratorParticipantTypes.contains(participantType);
|
||||||
|
return isCreator || isModerator;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_reactions_response.dart';
|
import 'get_reactions_response.dart';
|
||||||
|
|
||||||
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
|
|||||||
: super('v1/reaction/$chatToken/$messageId', null);
|
: super('v1/reaction/$chatToken/$messageId', null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetReactionsResponse assemble(String raw) {
|
GetReactionsResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetReactionsResponse.fromJson(
|
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Response>? request(
|
Future<Response>? request(
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
import 'get_shared_items_response.dart';
|
||||||
|
|
||||||
|
/// Fetches the messages that shared an item of a given [objectType] in a chat
|
||||||
|
/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using
|
||||||
|
/// the `X-Chat-Last-Given` response header (see
|
||||||
|
/// [GetSharedItemsResponse.lastGivenMessageId]).
|
||||||
|
///
|
||||||
|
/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`,
|
||||||
|
/// `deckcard`, `recording`, `other`.
|
||||||
|
class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
|
||||||
|
GetSharedItems(
|
||||||
|
String token, {
|
||||||
|
required String objectType,
|
||||||
|
int limit = 20,
|
||||||
|
int? lastKnownMessageId,
|
||||||
|
}) : super(
|
||||||
|
'v1/chat/$token/share',
|
||||||
|
null,
|
||||||
|
getParameters: {
|
||||||
|
'objectType': objectType,
|
||||||
|
'limit': limit,
|
||||||
|
'lastKnownMessageId': ?lastKnownMessageId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetSharedItemsResponse assemble(String raw) =>
|
||||||
|
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.get(uri, headers: headers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
import 'get_shared_items_overview_response.dart';
|
||||||
|
|
||||||
|
/// Fetches the latest shared items of every type at once (Talk's
|
||||||
|
/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to
|
||||||
|
/// show and to seed their first page. [limit] caps the items returned per type.
|
||||||
|
class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
|
||||||
|
GetSharedItemsOverview(String token, {int limit = 20})
|
||||||
|
: super(
|
||||||
|
'v1/chat/$token/share/overview',
|
||||||
|
null,
|
||||||
|
getParameters: {'limit': limit},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetSharedItemsOverviewResponse assemble(String raw) =>
|
||||||
|
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
Object? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.get(uri, headers: headers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import '../../../api_response.dart';
|
||||||
|
import '../chat/get_chat_response.dart';
|
||||||
|
|
||||||
|
/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared
|
||||||
|
/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`,
|
||||||
|
/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the
|
||||||
|
/// message structure.
|
||||||
|
class GetSharedItemsOverviewResponse extends ApiResponse {
|
||||||
|
final Map<String, List<GetChatResponseObject>> itemsByType;
|
||||||
|
|
||||||
|
GetSharedItemsOverviewResponse(this.itemsByType);
|
||||||
|
|
||||||
|
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||||
|
final data = ocs['data'];
|
||||||
|
final result = <String, List<GetChatResponseObject>>{};
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
for (final entry in data.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
final raw = switch (value) {
|
||||||
|
List<dynamic> list => list,
|
||||||
|
Map<dynamic, dynamic> map => map.values,
|
||||||
|
_ => const <dynamic>[],
|
||||||
|
};
|
||||||
|
result[entry.key] = raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(GetChatResponseObject.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GetSharedItemsOverviewResponse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import '../../../api_response.dart';
|
||||||
|
import '../chat/get_chat_response.dart';
|
||||||
|
|
||||||
|
/// Response of Talk's shared-items endpoints. The message objects carry the
|
||||||
|
/// same structure as regular chat messages, so we reuse [GetChatResponseObject]
|
||||||
|
/// (the shared file lives in `messageParameters['file']`).
|
||||||
|
///
|
||||||
|
/// The server returns `data` either as a list or as a message-id-keyed map
|
||||||
|
/// depending on version; [fromOcs] normalises both to a list.
|
||||||
|
class GetSharedItemsResponse extends ApiResponse {
|
||||||
|
final List<GetChatResponseObject> items;
|
||||||
|
|
||||||
|
GetSharedItemsResponse(this.items);
|
||||||
|
|
||||||
|
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
|
||||||
|
final data = ocs['data'];
|
||||||
|
final raw = switch (data) {
|
||||||
|
List<dynamic> list => list,
|
||||||
|
Map<dynamic, dynamic> map => map.values,
|
||||||
|
_ => const <dynamic>[],
|
||||||
|
};
|
||||||
|
final items = raw
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(GetChatResponseObject.fromJson)
|
||||||
|
.toList();
|
||||||
|
return GetSharedItemsResponse(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset for the next page, taken from the `X-Chat-Last-Given` header.
|
||||||
|
/// Null when the header is absent (older server) — treat that as "stop".
|
||||||
|
int? get lastGivenMessageId {
|
||||||
|
final value = headers?['x-chat-last-given'];
|
||||||
|
return value == null ? null : int.tryParse(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_room_params.dart';
|
import 'get_room_params.dart';
|
||||||
import 'get_room_response.dart';
|
import 'get_room_response.dart';
|
||||||
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
|||||||
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetRoomResponse assemble(String raw) {
|
GetRoomResponse assemble(String raw) =>
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../api_params.dart';
|
import '../../api_params.dart';
|
||||||
import '../../api_request.dart';
|
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
|
||||||
import '../../errors/network_exception.dart';
|
import '../../errors/network_exception.dart';
|
||||||
import '../../errors/not_found_exception.dart';
|
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
enum TalkApiMethod { get, post, put, delete }
|
abstract class TalkApi<T extends ApiResponse?> {
|
||||||
|
|
||||||
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
|
||||||
String path;
|
String path;
|
||||||
ApiParams? body;
|
ApiParams? body;
|
||||||
Map<String, String>? headers;
|
Map<String, String>? headers;
|
||||||
Map<String, dynamic>? getParameters;
|
Map<String, dynamic>? getParameters;
|
||||||
|
|
||||||
http.Response? response;
|
|
||||||
|
|
||||||
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
||||||
|
|
||||||
Future<http.Response>? request(
|
Future<http.Response>? request(
|
||||||
@@ -40,43 +31,25 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
|||||||
);
|
);
|
||||||
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
||||||
|
|
||||||
final http.Response data;
|
final data = await sendGuarded(
|
||||||
try {
|
'Talk $endpoint',
|
||||||
final raw = await request(endpoint, body, mergedHeaders);
|
() => request(endpoint, body, mergedHeaders),
|
||||||
if (raw == null) {
|
);
|
||||||
|
if (data == null) {
|
||||||
throw const NetworkException(
|
throw const NetworkException(
|
||||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||||
technicalDetails: 'Talk request returned null',
|
technicalDetails: 'Talk request returned null',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
data = raw;
|
|
||||||
} on SocketException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
|
||||||
} on TimeoutException catch (e) {
|
|
||||||
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
|
|
||||||
} on http.ClientException catch (e) {
|
|
||||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final status = data.statusCode;
|
final status = data.statusCode;
|
||||||
if (status < 200 || status >= 300) {
|
if (status < 200 || status >= 300) {
|
||||||
// Talk's OCS errors carry the real reason in the body (expired session,
|
// Talk's OCS errors carry the real reason in the body (expired session,
|
||||||
// removed participant, ...); include a trimmed preview so the dialog and
|
// removed participant, ...); include a trimmed preview so the dialog and
|
||||||
// logs surface the cause instead of just the bare status code.
|
// logs surface the cause instead of just the bare status code.
|
||||||
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
|
||||||
final detail = body.isEmpty
|
|
||||||
? 'Talk $endpoint -> HTTP $status'
|
|
||||||
: 'Talk $endpoint -> HTTP $status body=$preview';
|
|
||||||
log(detail);
|
log(detail);
|
||||||
if (status == 401) {
|
throwForStatus(status, detail);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../api_params.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import '../get_poll/get_poll_state_response.dart';
|
||||||
|
import '../talk_api.dart';
|
||||||
|
import 'vote_poll_params.dart';
|
||||||
|
|
||||||
|
class VotePoll extends TalkApi<GetPollStateResponse> {
|
||||||
|
// Body als echtes JSON (nicht form-encoded wie die anderen Endpunkte): nur
|
||||||
|
// so kommt das int-Array an; sonst liest der Server optionIds als [] und
|
||||||
|
// löscht die eigene Stimme (Ursache des Readonly-Fallbacks, Issue #42).
|
||||||
|
VotePoll({
|
||||||
|
required String token,
|
||||||
|
required int pollId,
|
||||||
|
required VotePollParams params,
|
||||||
|
}) : super(
|
||||||
|
'v1/poll/$token/$pollId',
|
||||||
|
params,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
GetPollStateResponse assemble(String raw) =>
|
||||||
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response>? request(
|
||||||
|
Uri uri,
|
||||||
|
ApiParams? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) {
|
||||||
|
if (body is! VotePollParams) return null;
|
||||||
|
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../api_params.dart';
|
||||||
|
|
||||||
|
part 'vote_poll_params.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class VotePollParams extends ApiParams {
|
||||||
|
/// Indizes der gewählten Optionen; leer = eigene Stimme zurückziehen.
|
||||||
|
List<int> optionIds;
|
||||||
|
|
||||||
|
VotePollParams({required this.optionIds});
|
||||||
|
factory VotePollParams.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$VotePollParamsFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$VotePollParamsToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'vote_poll_params.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
|
||||||
|
VotePollParams(
|
||||||
|
optionIds: (json['optionIds'] as List<dynamic>)
|
||||||
|
.map((e) => (e as num).toInt())
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
|
||||||
|
<String, dynamic>{'optionIds': instance.optionIds};
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import '../../../../api_response.dart';
|
|
||||||
import '../../webdav_api.dart';
|
|
||||||
import 'download_file_params.dart';
|
|
||||||
|
|
||||||
class DownloadFile extends WebdavApi<DownloadFileParams> {
|
|
||||||
DownloadFileParams params;
|
|
||||||
|
|
||||||
DownloadFile(this.params) : super(params);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ApiResponse> run() async {
|
|
||||||
throw UnimplementedError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
import '../../../../api_params.dart';
|
|
||||||
|
|
||||||
part 'download_file_params.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
class DownloadFileParams extends ApiParams {
|
|
||||||
String webdavSourcePath;
|
|
||||||
String localTargetPath;
|
|
||||||
String filename;
|
|
||||||
|
|
||||||
DownloadFileParams(
|
|
||||||
this.webdavSourcePath,
|
|
||||||
this.localTargetPath,
|
|
||||||
this.filename,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory DownloadFileParams.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$DownloadFileParamsFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$DownloadFileParamsToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'download_file_params.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
DownloadFileParams _$DownloadFileParamsFromJson(Map<String, dynamic> json) =>
|
|
||||||
DownloadFileParams(
|
|
||||||
json['webdavSourcePath'] as String,
|
|
||||||
json['localTargetPath'] as String,
|
|
||||||
json['filename'] as String,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadFileParamsToJson(DownloadFileParams instance) =>
|
|
||||||
<String, dynamic>{
|
|
||||||
'webdavSourcePath': instance.webdavSourcePath,
|
|
||||||
'localTargetPath': instance.localTargetPath,
|
|
||||||
'filename': instance.filename,
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
part 'download_file_response.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
class DownloadFileResponse {
|
|
||||||
String path;
|
|
||||||
|
|
||||||
DownloadFileResponse(this.path);
|
|
||||||
|
|
||||||
factory DownloadFileResponse.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$DownloadFileResponseFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$DownloadFileResponseToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'download_file_response.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
DownloadFileResponse _$DownloadFileResponseFromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
) => DownloadFileResponse(json['path'] as String);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadFileResponseToJson(
|
|
||||||
DownloadFileResponse instance,
|
|
||||||
) => <String, dynamic>{'path': instance.path};
|
|
||||||
@@ -2,24 +2,36 @@ import 'package:nextcloud/nextcloud.dart';
|
|||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../model/account_data.dart';
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../api_request.dart';
|
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
|
|
||||||
abstract class WebdavApi<T> extends ApiRequest {
|
abstract class WebdavApi<T> {
|
||||||
T genericParams;
|
T genericParams;
|
||||||
|
|
||||||
WebdavApi(this.genericParams) {
|
WebdavApi(this.genericParams);
|
||||||
establishWebdavConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ApiResponse> run();
|
Future<ApiResponse> run();
|
||||||
|
|
||||||
static Future<WebDavClient> webdav = establishWebdavConnection();
|
static Future<WebDavClient>? _webdav;
|
||||||
|
static String? _webdavSecret;
|
||||||
|
|
||||||
|
/// Shared WebDAV client. Rebuilt whenever the effective Nextcloud secret
|
||||||
|
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||||
|
/// authenticating with stale credentials.
|
||||||
|
static Future<WebDavClient> get webdav {
|
||||||
|
final secret = AccountData().getNextcloudSecret();
|
||||||
|
if (_webdav == null || _webdavSecret != secret) {
|
||||||
|
_webdavSecret = secret;
|
||||||
|
_webdav = establishWebdavConnection();
|
||||||
|
}
|
||||||
|
return _webdav!;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<WebDavClient> establishWebdavConnection() async =>
|
static Future<WebDavClient> establishWebdavConnection() async =>
|
||||||
NextcloudClient(
|
NextcloudClient(
|
||||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||||
password: AccountData().getPassword(),
|
// App password preferred — with 2FA the real password is not accepted
|
||||||
|
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
||||||
|
password: AccountData().getNextcloudSecret(),
|
||||||
loginName: AccountData().getUsername(),
|
loginName: AccountData().getUsername(),
|
||||||
).webdav;
|
).webdav;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
// Token mitschicken statt ein eigenes 401 einzufangen.
|
// Token mitschicken statt ein eigenes 401 einzufangen.
|
||||||
final pending = _pendingReLogin;
|
final pending = _pendingReLogin;
|
||||||
if (pending != null) await pending;
|
if (pending != null) await pending;
|
||||||
final token = await _tokenStorage.readToken();
|
// Reading the keystore can throw while the device is locked (iOS
|
||||||
|
// errSecInteractionNotAllowed on background requests). Degrade to an
|
||||||
|
// unauthenticated request instead of surfacing a platform error.
|
||||||
|
String? token;
|
||||||
|
try {
|
||||||
|
token = await _tokenStorage.readToken();
|
||||||
|
} catch (_) {
|
||||||
|
token = null;
|
||||||
|
}
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
/// `first_unlock` accessibility so the token can be read during background
|
||||||
|
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||||
|
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
||||||
|
/// `-25308 errSecInteractionNotAllowed` when the device is locked.
|
||||||
|
const IOSOptions _mcIosOptions = IOSOptions(
|
||||||
|
accessibility: KeychainAccessibility.first_unlock,
|
||||||
|
);
|
||||||
|
|
||||||
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
/// separate from `AccountData` because the username/password live on (Nextcloud
|
||||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||||
@@ -11,7 +19,7 @@ class MarianumConnectTokenStorage {
|
|||||||
final FlutterSecureStorage _storage;
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
const MarianumConnectTokenStorage([
|
const MarianumConnectTokenStorage([
|
||||||
this._storage = const FlutterSecureStorage(),
|
this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||||
|
|||||||
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
|
|||||||
class MarianumConnectApi {
|
class MarianumConnectApi {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 20);
|
static const Duration _receiveTimeout = Duration(seconds: 20);
|
||||||
|
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
static final Dio _instance = _build();
|
static final Dio _instance = _build();
|
||||||
|
|
||||||
static Dio dio() => _instance;
|
static Dio dio() => _instance;
|
||||||
|
|
||||||
|
/// A fresh dio with the standard JSON options but no interceptors — used by
|
||||||
|
/// the auth queries (login/verify) that must bypass the bearer/demo
|
||||||
|
/// interceptors to avoid a re-auth loop.
|
||||||
|
static Dio plainDio() => Dio(
|
||||||
|
BaseOptions(
|
||||||
|
connectTimeout: _connectTimeout,
|
||||||
|
sendTimeout: _connectTimeout,
|
||||||
|
receiveTimeout: _plainReceiveTimeout,
|
||||||
|
responseType: ResponseType.json,
|
||||||
|
contentType: 'application/json',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
static Dio _build() {
|
static Dio _build() {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'errors/marianumconnect_error.dart';
|
||||||
|
import 'marianumconnect_api.dart';
|
||||||
|
import 'marianumconnect_endpoint.dart';
|
||||||
|
|
||||||
|
/// Shared base for MarianumConnect API queries. Owns the [dio] client (the
|
||||||
|
/// shared authenticated singleton by default) and routes calls through [guard]
|
||||||
|
/// so every query maps a DioException to the app's typed AppExceptions the same
|
||||||
|
/// way instead of repeating the try/catch. Subclasses with bespoke error or
|
||||||
|
/// lifecycle handling (own dio, silent failure, custom status mapping) may skip
|
||||||
|
/// [guard] and still reuse [dio]/[endpoint].
|
||||||
|
abstract class MarianumConnectQuery {
|
||||||
|
final Dio dio;
|
||||||
|
|
||||||
|
MarianumConnectQuery({Dio? dio}) : dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
/// Resolves [path] against the active mobile-API base URL.
|
||||||
|
String endpoint(String path) => MarianumConnectEndpoint.resolve(path);
|
||||||
|
|
||||||
|
/// Runs [body], converting any DioException into the matching AppException.
|
||||||
|
Future<T> guard<T>(Future<T> Function() body) async {
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||||
|
Future<T> getObject<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic> json) fromJson, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
|
endpoint(path),
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
|
return fromJson(response.data!);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// GETs [path] and maps each element of the JSON array body with [fromJson].
|
||||||
|
Future<List<T>> getList<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic> json) fromJson, {
|
||||||
|
Map<String, dynamic>? queryParameters,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(
|
||||||
|
endpoint(path),
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
|
return response.data!
|
||||||
|
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||||
|
String isoDate(DateTime d) =>
|
||||||
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
|
/// GETs the selectable classes for the absence form (`absence/classes`). The
|
||||||
|
/// body is a bare JSON string array, so [getList] (which maps objects) does not
|
||||||
|
/// fit — read the raw list and cast.
|
||||||
|
class AbsenceClasses extends MarianumConnectQuery {
|
||||||
|
AbsenceClasses({super.dio});
|
||||||
|
|
||||||
|
Future<List<String>> run() => guard(() async {
|
||||||
|
final response = await dio.get<List<dynamic>>(endpoint('absence/classes'));
|
||||||
|
return response.data!.cast<String>();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import 'absence_prefill_response.dart';
|
||||||
|
|
||||||
|
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
|
||||||
|
class AbsencePrefill extends MarianumConnectQuery {
|
||||||
|
AbsencePrefill({super.dio});
|
||||||
|
|
||||||
|
Future<AbsencePrefillResponse> run() =>
|
||||||
|
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'absence_prefill_response.g.dart';
|
||||||
|
|
||||||
|
/// Prefill for the absence-report form: identity from LDAP plus the phone
|
||||||
|
/// number from the user's last report (empty strings when unknown).
|
||||||
|
@JsonSerializable()
|
||||||
|
class AbsencePrefillResponse {
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String firstName;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String lastName;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String className;
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
final String phone;
|
||||||
|
|
||||||
|
AbsencePrefillResponse({
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.className,
|
||||||
|
required this.phone,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AbsencePrefillResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$AbsencePrefillResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$AbsencePrefillResponseToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'absence_prefill_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => AbsencePrefillResponse(
|
||||||
|
firstName: json['firstName'] as String? ?? '',
|
||||||
|
lastName: json['lastName'] as String? ?? '',
|
||||||
|
className: json['className'] as String? ?? '',
|
||||||
|
phone: json['phone'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$AbsencePrefillResponseToJson(
|
||||||
|
AbsencePrefillResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'firstName': instance.firstName,
|
||||||
|
'lastName': instance.lastName,
|
||||||
|
'className': instance.className,
|
||||||
|
'phone': instance.phone,
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
|
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
|
||||||
|
/// Empty identity fields are backfilled from LDAP server-side; validation
|
||||||
|
/// (all fields required, class must exist, no past start date, end >= start)
|
||||||
|
/// also runs server-side and mirrors the client checks.
|
||||||
|
class AbsenceSubmit extends MarianumConnectQuery {
|
||||||
|
AbsenceSubmit({super.dio});
|
||||||
|
|
||||||
|
Future<void> run({
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
required String className,
|
||||||
|
required DateTime absentFrom,
|
||||||
|
required DateTime absentUntil,
|
||||||
|
required String phone,
|
||||||
|
required String note,
|
||||||
|
}) => guard(() async {
|
||||||
|
await dio.post<void>(
|
||||||
|
endpoint('absence'),
|
||||||
|
data: {
|
||||||
|
'firstName': firstName,
|
||||||
|
'lastName': lastName,
|
||||||
|
'className': className,
|
||||||
|
'absentFrom': isoDate(absentFrom),
|
||||||
|
'absentUntil': isoDate(absentUntil),
|
||||||
|
'phone': phone,
|
||||||
|
'note': note,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,46 +1,31 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_api.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'auth_login_response.dart';
|
import 'auth_login_response.dart';
|
||||||
|
|
||||||
/// Performs the Marianum-Connect bearer login. Used both by the foreground
|
/// Performs the Marianum-Connect bearer login. Used both by the foreground
|
||||||
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
|
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
|
||||||
/// run through the shared dio instance — that one has the interceptor, which
|
/// run through the shared dio instance — that one has the interceptor, which
|
||||||
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
||||||
class AuthLogin {
|
class AuthLogin extends MarianumConnectQuery {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthLogin({
|
AuthLogin({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
_dio =
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
dio ??
|
|
||||||
Dio(
|
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: _connectTimeout,
|
|
||||||
receiveTimeout: _receiveTimeout,
|
|
||||||
sendTimeout: _connectTimeout,
|
|
||||||
responseType: ResponseType.json,
|
|
||||||
contentType: 'application/json',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<AuthLoginResponse> run({
|
Future<AuthLoginResponse> run({
|
||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
required String tokenName,
|
required String tokenName,
|
||||||
}) async {
|
}) => guard(() async {
|
||||||
try {
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
endpoint('auth/login'),
|
||||||
MarianumConnectEndpoint.resolve('auth/login'),
|
|
||||||
data: {
|
data: {
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
@@ -54,8 +39,5 @@ class AuthLogin {
|
|||||||
expiresAt: payload.expiresAt,
|
expiresAt: payload.expiresAt,
|
||||||
);
|
);
|
||||||
return payload;
|
return payload;
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../marianumconnect_api.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
|
|
||||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||||
/// — a network error still clears the local token so the user isn't stuck with
|
/// — a network error still clears the local token so the user isn't stuck with
|
||||||
/// an unusable session.
|
/// an unusable session.
|
||||||
class AuthLogout {
|
class AuthLogout extends MarianumConnectQuery {
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthLogout({
|
AuthLogout({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
super.dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage;
|
||||||
_dio = dio ?? MarianumConnectApi.dio();
|
|
||||||
|
|
||||||
Future<void> run() async {
|
Future<void> run() async {
|
||||||
try {
|
try {
|
||||||
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
|
await dio.post<void>(endpoint('auth/logout'));
|
||||||
} on DioException catch (_) {
|
} on DioException catch (_) {
|
||||||
// ignore — local clear below still happens
|
// ignore — local clear below still happens
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../../errors/auth_exception.dart';
|
import '../../../errors/auth_exception.dart';
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
import '../../marianumconnect_api.dart';
|
||||||
import '../../marianumconnect_endpoint.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Probes that the stored bearer token still maps to the given credentials.
|
/// Probes that the stored bearer token still maps to the given credentials.
|
||||||
/// Server returns 200 only when the credentials belong to the user that the
|
/// Server returns 200 only when the credentials belong to the user that the
|
||||||
@@ -12,29 +12,15 @@ import '../../marianumconnect_endpoint.dart';
|
|||||||
///
|
///
|
||||||
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||||
/// and obscure a real 401 with a silent re-login.
|
/// and obscure a real 401 with a silent re-login.
|
||||||
class AuthVerify {
|
class AuthVerify extends MarianumConnectQuery {
|
||||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
|
||||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
|
||||||
|
|
||||||
final MarianumConnectTokenStorage _tokenStorage;
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
final Dio _dio;
|
|
||||||
|
|
||||||
AuthVerify({
|
AuthVerify({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
Dio? dio,
|
Dio? dio,
|
||||||
}) : _tokenStorage = tokenStorage,
|
}) : _tokenStorage = tokenStorage,
|
||||||
_dio =
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
dio ??
|
|
||||||
Dio(
|
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: _connectTimeout,
|
|
||||||
sendTimeout: _connectTimeout,
|
|
||||||
receiveTimeout: _receiveTimeout,
|
|
||||||
responseType: ResponseType.json,
|
|
||||||
contentType: 'application/json',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
||||||
/// user, token missing, or token rejected), other [AppException]s on
|
/// user, token missing, or token rejected), other [AppException]s on
|
||||||
@@ -49,14 +35,12 @@ class AuthVerify {
|
|||||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
technicalDetails: 'AuthVerify: no bearer token in storage',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
return guard(() async {
|
||||||
await _dio.post<void>(
|
await dio.post<void>(
|
||||||
MarianumConnectEndpoint.resolve('auth/verify'),
|
endpoint('auth/verify'),
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
});
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_breakers_response.dart';
|
import 'get_breakers_response.dart';
|
||||||
|
|
||||||
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
|
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
|
||||||
/// The endpoint is public: the bearer token is attached if present but not
|
/// The endpoint is public: the bearer token is attached if present but not
|
||||||
/// required, so this also works before login (e.g. to block the whole app).
|
/// required, so this also works before login (e.g. to block the whole app).
|
||||||
class GetBreakers {
|
class GetBreakers extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetBreakers({super.dio});
|
||||||
|
|
||||||
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<GetBreakersResponse> run() =>
|
||||||
|
getObject('breaker', GetBreakersResponse.fromJson);
|
||||||
Future<GetBreakersResponse> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('breaker'),
|
|
||||||
);
|
|
||||||
return GetBreakersResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ enum BreakerArea {
|
|||||||
files,
|
files,
|
||||||
@JsonValue('NEWS')
|
@JsonValue('NEWS')
|
||||||
news,
|
news,
|
||||||
|
@JsonValue('TICKER')
|
||||||
|
ticker,
|
||||||
@JsonValue('ROOMPLAN')
|
@JsonValue('ROOMPLAN')
|
||||||
roomPlan,
|
roomPlan,
|
||||||
@JsonValue('GRADES')
|
@JsonValue('GRADES')
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
|
|||||||
BreakerArea.talk: 'TALK',
|
BreakerArea.talk: 'TALK',
|
||||||
BreakerArea.files: 'FILES',
|
BreakerArea.files: 'FILES',
|
||||||
BreakerArea.news: 'NEWS',
|
BreakerArea.news: 'NEWS',
|
||||||
|
BreakerArea.ticker: 'TICKER',
|
||||||
BreakerArea.roomPlan: 'ROOMPLAN',
|
BreakerArea.roomPlan: 'ROOMPLAN',
|
||||||
BreakerArea.grades: 'GRADES',
|
BreakerArea.grades: 'GRADES',
|
||||||
BreakerArea.holidays: 'HOLIDAYS',
|
BreakerArea.holidays: 'HOLIDAYS',
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import 'package:dio/dio.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_api.dart';
|
|
||||||
import '../../marianumconnect_endpoint.dart';
|
|
||||||
import 'get_capabilities_response.dart';
|
import 'get_capabilities_response.dart';
|
||||||
|
|
||||||
/// Fetches the current user's mobile capability flags from
|
/// Fetches the current user's mobile capability flags from
|
||||||
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
||||||
/// so the bearer token is attached automatically.
|
/// so the bearer token is attached automatically.
|
||||||
class GetCapabilities {
|
class GetCapabilities extends MarianumConnectQuery {
|
||||||
final Dio _dio;
|
GetCapabilities({super.dio});
|
||||||
|
|
||||||
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
Future<CapabilitiesResponse> run() =>
|
||||||
|
getObject('me/capabilities', CapabilitiesResponse.fromJson);
|
||||||
Future<CapabilitiesResponse> run() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<Map<String, dynamic>>(
|
|
||||||
MarianumConnectEndpoint.resolve('me/capabilities'),
|
|
||||||
);
|
|
||||||
return CapabilitiesResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw mapMarianumConnectError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,23 @@ class CapabilitiesResponse {
|
|||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
final bool pushNotifications;
|
final bool pushNotifications;
|
||||||
|
|
||||||
|
/// How many days into the past/future the user may view the timetable.
|
||||||
|
/// `null` (absent) means unlimited — the school year alone governs. The
|
||||||
|
/// backend widens both to at least cover the current Mon–Sun week.
|
||||||
|
final int? timetablePastDays;
|
||||||
|
|
||||||
|
final int? timetableFutureDays;
|
||||||
|
|
||||||
|
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
|
||||||
|
/// predates the field or has no LDAP record for the user.
|
||||||
|
final String? userType;
|
||||||
|
|
||||||
CapabilitiesResponse({
|
CapabilitiesResponse({
|
||||||
required this.viewForeignTimetables,
|
required this.viewForeignTimetables,
|
||||||
required this.pushNotifications,
|
required this.pushNotifications,
|
||||||
|
this.timetablePastDays,
|
||||||
|
this.timetableFutureDays,
|
||||||
|
this.userType,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user