59 Commits

Author SHA1 Message Date
MineTec ccb22a497d added support for 2fa login with browser flow 2026-08-10 20:00:38 +02:00
MineTec 889d8f67c5 show classname instead of teacher name in teacher timetable view 2026-08-09 12:19:57 +02:00
MineTec 39c16bd4ea enhanced route transition gate because of a syncfusion viewer bug 2026-08-08 22:13:19 +02:00
MineTec b9cb1df473 added cache folder sweep check before file actions, preventing errors 2026-08-07 21:36:09 +02:00
MineTec 62fa337188 added integration_test to deps 2026-08-07 21:25:08 +02:00
MineTec c0dadf8b6e bumped minor version to 1.5.1+61 2026-08-06 20:30:20 +02:00
MineTec ab23422a86 widget refresh enhancements 2026-08-06 20:22:32 +02:00
MineTec 646e2c0451 removed timetable limits, fully dependent on marianumConnect now 2026-08-05 20:03:02 +02:00
MineTec 246cb0f527 simplified loadable state logic by removing connectivity debouncing and inlining indicator helpers; updated error bar visibility and chat list polling behavior 2026-08-02 08:21:25 +02:00
MineTec 4c2e9b47e7 Merge remote-tracking branch 'origin/develop' into develop 2026-08-01 18:19:56 +02:00
MineTec 2d690736e3 implemented connectivity debouncing and refetch throttling for loadable state UI components 2026-08-01 18:16:22 +02:00
MineTec 778c473631 added telemetry call on app resume with 15 minute cooldown 2026-07-28 12:52:07 +02:00
MineTec 7e9cbcf1e9 fixed widget mounting errors 2026-07-28 12:51:39 +02:00
MineTec 32be67426c Merge remote-tracking branch 'origin/develop' into develop 2026-07-26 13:49:05 +02:00
MineTec b957189fd3 implemented absence report module with form validation, prefill support, and API integration 2026-07-26 11:45:48 +02:00
MineTec 624c5512a6 fixed failing test 2026-07-23 14:22:32 +02:00
MineTec 3493cf8617 fixed error on pdf viewer transition 2026-07-23 14:16:33 +02:00
MineTec 75080a2c49 implemented shared items view for Talk chats with tabbed categories and on-scroll pagination 2026-07-19 10:58:02 +02:00
MineTec f3cd7896d9 implemented backward pagination for chat history with on-scroll prefetching 2026-07-17 20:14:47 +02:00
Marianum 101e7c909c bump version to 1.4.0+59 2026-07-17 15:19:26 +02:00
Marianum 429f1e6f96 fix build against updated dependencies: re-add DioExceptionType.transformTimeout case (restored in dio 5.10.0) and rename SwiftReceiveSharingIntentPlugin to ReceiveSharingIntentPlugin (renamed in receive_sharing_intent 1.9.0); refresh Podfile.lock and SPM Package.resolved 2026-07-17 15:19:10 +02:00
Marianum a0c55a811c completed iOS push notification setup: created the NotificationServiceExtension Xcode target programmatically (via xcodeproj), embedded it in Runner, and added its xcconfig base configs; fixed Automatic Signing by switching the NSE↔Runner shared keychain from an app-group to a team-prefixed access group across entitlements, Dart and Swift; fixed the analyzer break from dio 5.9.2 dropping DioExceptionType.transformTimeout 2026-07-17 15:05:34 +02:00
MineTec e625216a90 improved push notification settings with health indicators and optimized notification dismissal 2026-07-16 22:40:33 +02:00
MineTec 8128cede21 refactored teacher display in lesson details to better handle substitutions and duplicates 2026-07-16 21:44:08 +02:00
MineTec e5f7cf0176 updated the ProseMirror table view to conditionally hide the mode toggle toolbar when content fits within the available width, ensuring the toggle is only visible for overflowing tables. 2026-07-16 21:43:52 +02:00
MineTec e349d667d4 implemented a 1-minute in-memory cache for the EmergencyNoticeClient to prevent redundant network requests on app resume 2026-07-16 10:47:51 +02:00
MineTec 7c1f5c06df implemented a backend-independent emergency notice system 2026-07-15 20:30:20 +02:00
MineTec e8c6ac1c65 further improved accessibility across the application and add tooltips to action buttons 2026-07-15 19:29:40 +02:00
MineTec 478f0ff20b implemented comprehensive accessibility (A11y) support across the app 2026-07-13 23:41:47 +02:00
MineTec 8274dd46cd refactored HTTP error handling and streamlined UI components by centralizing shared logic and removing redundant parameters 2026-07-13 22:58:02 +02:00
MineTec d7536ea5d0 remove dead endpoint staging mode (obsoleted by demo mode) 2026-07-13 22:32:38 +02:00
MineTec 398b147c76 simplify: dedupe boilerplate and remove dead code across all layers 2026-07-13 22:22:39 +02:00
MineTec 15791423ea added fileFetchedAt to TickerPageResponse and updated the Ticker UI to use it as the data currency timestamp for proxied files 2026-07-13 00:32:27 +02:00
MineTec 2f5a6b4ce0 split file_viewer into focused sub-widgets 2026-07-13 00:01:07 +02:00
MineTec 53bc6d5360 share weekKey extension and log splash precache failures 2026-07-12 23:56:47 +02:00
MineTec f50359b4eb use AppProgressIndicator for plain loading spinners 2026-07-12 23:45:55 +02:00
MineTec 9994a1f3fa extract SettingsDropdownTile for settings dropdowns 2026-07-12 23:42:56 +02:00
MineTec 4aa31a2e44 extract SettingsCheckboxTile for settings toggles 2026-07-12 23:41:12 +02:00
MineTec dfce3e7b5c add PromptDialog helper for text input dialogs 2026-07-12 23:36:46 +02:00
MineTec 564a334cdc route avatar crop and profile view through AppRoutes 2026-07-12 23:32:48 +02:00
MineTec db329c7299 adopt MarianumConnectQuery base in remaining queries 2026-07-12 23:28:51 +02:00
MineTec 0a2ff5c3fb add MarianumConnectQuery base and adopt it in list queries 2026-07-12 23:24:21 +02:00
MineTec 9b5198c6db add ticker page bloc and avatar disk cache 2026-07-12 23:18:53 +02:00
MineTec 94794ff092 remove unused dead code files 2026-07-12 23:17:47 +02:00
MineTec fe2b3c43b2 implemented custom subject colors for the timetable and unified the color palette system 2026-07-12 21:30:08 +02:00
MineTec a7111844b1 bugfixes, better background operation robustness and platform-specific error handling 2026-07-12 19:17:49 +02:00
MineTec babc347b18 implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic 2026-07-12 19:05:27 +02:00
MineTec 44e45c9b78 implemented full interactive poll support in Talk, including creation, voting, and closing functionality 2026-07-12 17:54:30 +02:00
MineTec 3f44e9302f implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog 2026-07-12 16:42:30 +02:00
MineTec 59501d3b45 implemented enlarged emoji rendering for chat messages containing up to three emojis 2026-07-12 16:26:22 +02:00
MineTec 91a6216f66 migrated custom timetable events to Marianum-Connect and refactored push notification handling 2026-07-12 14:31:48 +02:00
MineTec c444ed54a5 migrated Marianum Message module to the Marianum-Connect API and implemented push notification deep linking 2026-07-11 17:37:50 +02:00
MineTec ff23199345 downgrade AGP and Gradle versions, update Ticker icon, and upgrade dependencies 2026-07-11 15:32:09 +02:00
MineTec 9545d2a946 upgraded Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9.4.1 2026-07-10 19:16:07 +02:00
MineTec 37608e59b3 implemented Ticker page selection persistence and enhanced ProseMirror table display modes 2026-07-10 19:15:52 +02:00
MineTec 0d01f6b631 fixed push-registration not activated on app-login 2026-07-10 15:35:56 +02:00
MineTec b0d2e7a34b fixed inconsistent placement of appbar title on iOS 2026-07-09 12:04:20 +02:00
MineTec cedeb06569 implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files 2026-07-09 00:51:08 +02:00
MineTec 1114291313 Add Fastlane Play Store integration with automated screenshots
Fastlane supply config (android/fastlane) with de-DE metadata structure,
integration_test-driven screenshot automation (screenshot_test.dart,
test_driver, tool/screenshots.sh) using stable keys on the login form,
refreshed materials/screenshots (phone + 7-inch) and app banner/symbol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:43:10 +02:00
416 changed files with 17799 additions and 3894 deletions
+12
View File
@@ -350,3 +350,15 @@ hs_err_pid*
*.idea*
**/.DS_store
# Fastlane (Play-Store-Upload)
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/play-service-account.json
android/fastlane/README.md.bak
# lokale Screenshots außerhalb der Fastlane-Metadaten
/screenshots/
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
+4
View File
@@ -65,5 +65,9 @@ flutter {
dependencies {
implementation 'com.android.support:multidex:2.0.1'
// Same version as workmanager_android pins — needed to enqueue its
// BackgroundWorker from native widget code (the plugin uses
// `implementation`, so androidx.work is not exposed transitively).
implementation 'androidx.work:work-runtime:2.11.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
}
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.os.Bundle
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
/**
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
*/
class TimetableDayWidget : AppWidgetProvider() {
override fun onEnabled(context: Context) {
// First widget of this kind placed → fetch fresh data right away.
WidgetRefreshRequester.enqueueOneOffRefresh(context)
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.os.Bundle
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
class TimetableWeekWidget : AppWidgetProvider() {
override fun onEnabled(context: Context) {
// First widget of this kind placed → fetch fresh data right away.
WidgetRefreshRequester.enqueueOneOffRefresh(context)
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
@@ -33,6 +33,8 @@ data class WidgetLesson(
val subjectShort: String,
val subjectLong: String?,
val room: String?,
// On teacher accounts this carries the class label ("7a") instead of the
// teacher short name (originalTeacher is null then) — mapped in Dart.
val teacher: String?,
val originalTeacher: String?,
val status: WidgetLessonStatus,
@@ -0,0 +1,40 @@
package eu.mhsl.marianum.mobile.client.widgets
import android.content.Context
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import dev.fluttercommunity.workmanager.BackgroundWorker
/**
* Enqueues the app's Dart one-off widget refresh from native code, so a
* freshly placed widget populates within seconds instead of waiting for the
* next periodic slot.
*
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
* persisted by Workmanager().initialize(); if the app never ran, the worker
* fails gracefully and the widget keeps rendering its cached/empty state.
*/
object WidgetRefreshRequester {
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
fun enqueueOneOffRefresh(context: Context) {
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
.setInputData(
Data.Builder()
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
.build(),
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
WorkManager.getInstance(context).enqueue(request)
}
}
@@ -736,8 +736,11 @@ object WidgetRenderer {
)
}
// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
// schema bump must land in Dart, Swift (WidgetData.swift) and here
// together, or the out-of-sync platform silently renders empty.
const val KEY_DAY_DATA = "widget_data_day_v1"
const val KEY_WEEK_DATA = "widget_data_week_v1"
const val KEY_WEEK_DATA = "widget_data_week_v2"
const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
}
+6
View File
@@ -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")
+29
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
# Plugin-Handling initialisiert.
+35
View File
@@ -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`).
+40
View File
@@ -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)
+215
View File
@@ -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
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
/// extension. Wrong value here => keychain reads return nil => placeholder.
private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
private static let serverPublicKeyAccount = "push_server_public_key_pem"
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
/// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim
/// kSecAttrService = (unset the Dart IOSOptions set no accountName)
/// kSecAttrAccessGroup = the App Group id
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
/// value = raw UTF-8 bytes of the string
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
@@ -8,7 +8,7 @@
</array>
<key>keychain-access-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
+36 -10
View File
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
@@ -67,6 +68,15 @@ iOS zeigt die fertige Notification
## 3. Xcode-Checkliste (auf dem Mac)
> **Stand 2026-07-07:** Abschnitte 3.13.2 (Target anlegen, Dateien zuordnen) sind
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.33.4, 3.6)** —
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
> aufgesetzt werden muss).
### 3.1 Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**.
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
## 4. Ermittelte Keychain-Details (verbindlich)
Die Dart-Seite schreibt mit
`IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`.
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
> Automatic Signing mit „doesn't match the entitlements file's value for the
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
@@ -141,7 +162,7 @@ Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
| `kSecClass` | `kSecClassGenericPassword` |
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
| `kSecAttrAccessGroup` | `group.eu.mhsl.marianum.mobile.client.widget` |
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
innerhalb des NSE-Budgets). Nicht implementiert.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
vollqualifizierten Group-String verwenden.
-12
View File
@@ -4,8 +4,6 @@ PODS:
- Flutter (1.0.0)
- flutter_app_badge (2.0.0):
- Flutter
- home_widget (0.0.1):
- Flutter
- open_filex (0.0.2):
- Flutter
- PhoneNumberKit (3.7.11):
@@ -14,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore
- receive_sharing_intent (1.8.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
@@ -23,10 +19,8 @@ DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`)
- Flutter (from `Flutter`)
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
@@ -40,12 +34,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_app_badge:
:path: ".symlinks/plugins/flutter_app_badge/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
@@ -53,10 +43,8 @@ SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
+188 -9
View File
@@ -12,14 +12,18 @@
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -37,6 +41,13 @@
remoteGlobalIDString = AA0101010000000011111111;
remoteInfo = TimetableWidgetExtension;
};
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
remoteInfo = NotificationServiceExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -48,6 +59,7 @@
files = (
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -65,17 +77,24 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -94,10 +113,12 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -169,6 +190,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -185,15 +214,36 @@
path = Pods;
sourceTree = "<group>";
};
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
isa = PBXGroup;
children = (
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
509DCCD474353408FE5806C5 /* PEM.swift */,
12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
);
name = NotificationServiceExtension;
path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT;
};
731388A08E3B330B216381D0 /* Frameworks */ = {
isa = PBXGroup;
children = (
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
isa = PBXGroup;
children = (
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -208,6 +258,9 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
@@ -222,6 +275,7 @@
97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */,
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
);
sourceTree = "<group>";
};
@@ -231,6 +285,7 @@
97C146EE1CF9000F007C117D /* Runner.app */,
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -278,9 +333,6 @@
productType = "com.apple.product-type.app-extension";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -299,8 +351,12 @@
dependencies = (
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
AA0101090000000011111111 /* PBXTargetDependency */,
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -325,13 +381,27 @@
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
buildPhases = (
DF8C1170E7DF96711030EAC0 /* Sources */,
D2447D92E9CCD96B3292B17B /* Frameworks */,
239068341DC6E6B36193EC96 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationServiceExtension;
productName = NotificationServiceExtension;
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -360,6 +430,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -367,11 +440,19 @@
97C146ED1CF9000F007C117D /* Runner */,
3321F8042FB1C00C0011C712 /* Share Extension */,
AA0101010000000011111111 /* TimetableWidgetExtension */,
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
239068341DC6E6B36193EC96 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3321F8032FB1C00C0011C712 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -520,6 +601,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
DF8C1170E7DF96711030EAC0 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -528,6 +618,12 @@
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
};
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationServiceExtension;
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
};
AA0101090000000011111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
@@ -929,6 +1025,29 @@
};
name = Release;
};
A2B046F78AD55232F08583F3 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA01010A0000000011111111 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
@@ -1051,6 +1170,54 @@
};
name = Profile;
};
E5EE58583E83B7694F6C3C5E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F418DF43742DC2E30B161652 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1094,13 +1261,25 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E5EE58583E83B7694F6C3C5E /* Release */,
A2B046F78AD55232F08583F3 /* Debug */,
F418DF43742DC2E30B161652 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.1"
}
},
{
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
"version" : "12.13.0"
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.15.0"
}
},
{
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
"version" : "3.5.0"
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.6.1"
}
},
{
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
"version" : "12.13.0"
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.15.0"
}
},
{
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.2"
}
},
{
@@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
"version" : "1.22.2"
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
@@ -131,8 +131,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
@@ -140,8 +140,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
+15 -1
View File
@@ -1,6 +1,7 @@
import Flutter
import UIKit
import UserNotifications
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -12,7 +13,7 @@ import UserNotifications
private let markReadActionId = "TALK_MARK_READ"
// Shared (App Group) keychain same group as the NSE and the Dart side.
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private let usernameAccount = "nextcloud_username"
private let appPasswordAccount = "nextcloud_app_password"
private let baseUrlAccount = "nextcloud_base_url"
@@ -23,6 +24,19 @@ import UserNotifications
) -> Bool {
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
registerTalkCategory()
// BGAppRefresh for the home-screen widget. Must all happen before
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
// plugins only during scene connection, which is past BGTaskScheduler's
// registration deadline registerLaunchHandlers() bridges that gap.
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
// plugin re-submits the refresh request itself after every run.
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "eu.mhsl.marianum.widget.refresh",
earliestBeginInSeconds: 1800)
WorkmanagerPlugin.registerLaunchHandlers()
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
// forwards these callbacks to the plugins (firebase_messaging,
// flutter_local_notifications). We route Talk actions natively here the
+4
View File
@@ -66,6 +66,10 @@
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
+1 -1
View File
@@ -11,7 +11,7 @@
</array>
<key>keychain-access-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
+2 -2
View File
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
)
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
open: context.url,
options: [:]
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
import AVFoundation
// Datenmodell muss byte-für-byte zu dem passen, was
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
private enum SharedMediaType: String, Codable {
case image, video, text, file, url
}
@@ -0,0 +1,109 @@
import Foundation
/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart`
/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync the Dart
/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the
/// review checklist here:
/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff)
/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff)
/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri Mon)
/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11
/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11
/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04
/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11
enum TimetableAnchor {
/// After 17:00 the user's question shifts from "what's left today" to
/// "what's tomorrow", so the day widget rolls forward.
static let dayCutoffHour = 17
static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
var candidate = calendar.startOfDay(for: now)
let shiftToTomorrow =
calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar)
if shiftToTomorrow {
candidate = nextDay(candidate, calendar: calendar)
}
while isWeekend(candidate, calendar: calendar) {
candidate = nextDay(candidate, calendar: calendar)
}
return candidate
}
static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
let anchor = resolveDayAnchor(now, calendar: calendar)
// Swift weekday: 1 = Sunday 7 = Saturday distance back to Monday.
let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor
return calendar.startOfDay(for: monday)
}
/// Future instants at which the rendered anchor can change: each day's
/// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted,
/// strictly after `now`.
static func boundaryDates(
from now: Date,
horizonDays: Int = 3,
calendar: Calendar = .current
) -> [Date] {
var result: [Date] = []
let today = calendar.startOfDay(for: now)
for offset in 0...horizonDays {
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
let midnight = calendar.startOfDay(for: day)
if midnight > now { result.append(midnight) }
if let cutoff = calendar.date(
bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day
), cutoff > now {
result.append(cutoff)
}
}
return result.sorted()
}
/// Derives a day payload from the 14-day week payload same shape the
/// Dart buildDayData produces, since the week payload runs through the
/// identical per-day merge/collision pipeline.
static func slice(
week: WidgetTimetableData,
forDay anchor: Date,
calendar: Calendar = .current
) -> WidgetTimetableData {
let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) }
// Every v2 week payload carries `days` the key bump guarantees it.
let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) }
return WidgetTimetableData(
fetchedAt: week.fetchedAt,
anchorDate: anchor,
lessons: lessons,
periods: week.periods,
isHoliday: dayInfo?.isHoliday ?? false,
holidayName: dayInfo?.holidayName,
days: nil
)
}
/// Week payload re-anchored to the week containing/following `anchorDate`.
/// The week view filters its five columns off `anchorDate`, so this alone
/// performs the Friday-evening/weekend jump to next week.
static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData {
WidgetTimetableData(
fetchedAt: week.fetchedAt,
anchorDate: anchorDate,
lessons: week.lessons,
periods: week.periods,
isHoliday: week.isHoliday,
holidayName: week.holidayName,
days: week.days
)
}
private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool {
let weekday = calendar.component(.weekday, from: date)
return weekday == 1 || weekday == 7
}
private static func nextDay(_ date: Date, calendar: Calendar) -> Date {
let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date
return calendar.startOfDay(for: next)
}
}
@@ -100,13 +100,13 @@ struct TimetableDayView: View {
private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 4) {
Text(dayLabel(for: data.anchorDate))
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
Spacer(minLength: 4)
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
@@ -383,6 +383,7 @@ struct TimeGridView: View {
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
}
}
}
@@ -400,9 +401,31 @@ func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
return result.sorted()
}
func dayLabel(for date: Date) -> String {
/// Fixed-locale formatters cached once DateFormatter setup is the
/// expensive part and the multi-entry timelines render up to ~8 entries per
/// reload. Only touched from WidgetKit's archival rendering, so the shared
/// instances are safe.
enum WidgetDateFormatters {
static let shortDate = make("dd.MM.")
static let weekdayShort = make("EE")
static let weekdayDate = make("EEEE · dd.MM.")
static let time = make("HH:mm")
static let dateTime = make("dd.MM. HH:mm")
private static func make(_ format: String) -> DateFormatter {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = format
return f
}
}
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
/// entries ahead of time, so wall-clock reads would be wrong for every
/// entry after the first.
func dayLabel(for date: Date, relativeTo now: Date) -> String {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let today = cal.startOfDay(for: now)
let anchor = cal.startOfDay(for: date)
if anchor == today {
return "Heute · \(shortDate(date))"
@@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String {
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
return "Morgen · \(shortDate(date))"
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.dateFormat = "EEEE · dd.MM."
return formatter.string(from: date)
return WidgetDateFormatters.weekdayDate.string(from: date)
}
func shortDate(_ date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM."
return f.string(from: date)
WidgetDateFormatters.shortDate.string(from: date)
}
func freshnessLabel(for fetchedAt: Date) -> String {
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let today = cal.startOfDay(for: now)
let fetchedDay = cal.startOfDay(for: fetchedAt)
let timeFmt = DateFormatter()
timeFmt.locale = Locale(identifier: "de_DE")
timeFmt.dateFormat = "HH:mm"
if fetchedDay == today {
return timeFmt.string(from: fetchedAt)
return WidgetDateFormatters.time.string(from: fetchedAt)
}
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
fetchedDay == yesterday {
return "gestern \(timeFmt.string(from: fetchedAt))"
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
}
let dateTimeFmt = DateFormatter()
dateTimeFmt.locale = Locale(identifier: "de_DE")
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
return dateTimeFmt.string(from: fetchedAt)
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
}
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer()
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
}
private func weekday(for date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "EE"
return f.string(from: date)
WidgetDateFormatters.weekdayShort.string(from: date)
}
private func placeholder(_ message: String) -> some View {
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void
) {
let entry = TimetableEntry.current(variant: .day)
let now = Date()
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
// advisory; the "Stand:" label tells the user when data is stale.
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
completion(Timeline(entries: [entry], policy: .after(next)))
// advisory; the boundary entries below keep the rendered day correct
// even when no reload is granted, and the "Stand:" label tells the
// user when the underlying data is stale.
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
}
}
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void
) {
let entry = TimetableEntry.current(variant: .week)
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
completion(Timeline(entries: [entry], policy: .after(next)))
let now = Date()
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
}
}
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
themeMode: WidgetDataLoader.themeMode()
)
}
/// Day timeline derived from the 14-day week payload, so the widget shows
/// the right day even when iOS grants no reload for days.
static func dayEntries(now: Date) -> [TimetableEntry] {
entries(now: now, variant: .day) { week, date in
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
}
}
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
/// into next week from cached data, and the midnight entries keep the
/// "Stand:" freshness label honest.
static func weekEntries(now: Date) -> [TimetableEntry] {
entries(now: now, variant: .week) { week, date in
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
}
}
/// Shared timeline skeleton: one entry now plus one per anchor boundary
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
/// render same anchor and same calendar day for the header labels
/// are dropped.
private static func entries(
now: Date,
variant: TimetableVariant,
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
) -> [TimetableEntry] {
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
// Logged out, or no v2 week snapshot yet (fresh app update):
// fall back to the legacy single-entry payload.
return [TimetableEntry.current(variant: variant)]
}
let theme = WidgetDataLoader.themeMode()
let cal = Calendar.current
var result: [TimetableEntry] = []
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
let data = transform(week, date)
if let previous = result.last, let previousData = previous.data,
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
cal.isDate(previous.date, inSameDayAs: date) {
continue
}
result.append(TimetableEntry(
date: date,
variant: variant,
data: data,
isLoggedIn: true,
themeMode: theme
))
}
return result
}
}
extension View {
+23 -1
View File
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
case irregular
case teacherChanged
case event
case duty
/// Unknown future statuses degrade to `.regular` instead of failing the
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) a
/// single new enum value must never blank the widget to the placeholder.
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = WidgetLessonStatus(rawValue: raw) ?? .regular
}
}
struct WidgetLesson: Codable {
@@ -18,6 +27,8 @@ struct WidgetLesson: Codable {
let subjectShort: String
let subjectLong: String?
let room: String?
// On teacher accounts this carries the class label ("7a") instead of the
// teacher short name (originalTeacher is nil then) mapped in Dart.
let teacher: String?
let originalTeacher: String?
let status: WidgetLessonStatus
@@ -33,6 +44,12 @@ struct WidgetPeriod: Codable {
let virtualEndMinutes: Int
}
struct WidgetDayInfo: Codable {
let date: Date
let isHoliday: Bool
let holidayName: String?
}
struct WidgetTimetableData: Codable {
let fetchedAt: Date
let anchorDate: Date
@@ -40,12 +57,17 @@ struct WidgetTimetableData: Codable {
let periods: [WidgetPeriod]
let isHoliday: Bool
let holidayName: String?
/// Week payload (v2) only; optional so day payloads keep decoding.
let days: [WidgetDayInfo]?
}
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) a
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
/// together, or the out-of-sync platform silently blanks to the placeholder.
enum WidgetDataKey {
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
static let dayData = "widget_data_day_v1"
static let weekData = "widget_data_week_v1"
static let weekData = "widget_data_week_v2"
static let loggedIn = "widget_data_logged_in_v1"
static let themeMode = "widget_setting_theme_mode_v1"
}
-1
View File
@@ -1 +0,0 @@
class ApiRequest {}
+17
View File
@@ -0,0 +1,17 @@
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
/// Demo fixtures for the absence-report form: the class dropdown and the
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
/// silently drift the demo shape away from what the queries parse.
class DemoAbsence {
const DemoAbsence._();
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
firstName: 'Max',
lastName: 'Mustermann',
className: '10a',
phone: '0123 456789',
);
}
+7 -1
View File
@@ -2,13 +2,19 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available.
/// demo persona sees every feature (incl. push) as available and the timetable
/// scroll range stays unlimited (null day counts).
class DemoCapabilities {
const DemoCapabilities._();
static CapabilitiesState state() => const CapabilitiesState(
viewForeignTimetables: true,
pushNotifications: true,
timetablePastDays: null,
timetableFutureDays: null,
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
// Feld nicht" (siehe CapabilitiesResponse.userType).
userType: 'STUDENT',
loaded: true,
);
}
+5 -3
View File
@@ -1,3 +1,5 @@
import 'package:intl/intl.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
/// Demo fixtures for the info messages — a single friendly welcome entry so the
@@ -6,16 +8,16 @@ class DemoMarianumMessage {
const DemoMarianumMessage._();
static MarianumMessageList list() {
final today = DateTime.now();
final date =
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
final date = DateFormat.yMMMM('de').format(DateTime.now());
return MarianumMessageList(
base: 'https://marianum-fulda.de',
messages: [
MarianumMessage(
id: 'demo',
name: 'Willkommen in der Marianum-App',
date: date,
description: 'Eine kurze Begrüßung zum Ausprobieren.',
url: 'https://marianum-fulda.de',
),
],
+112
View File
@@ -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',
);
}
+50
View File
@@ -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();
}
}
+10
View File
@@ -1,6 +1,8 @@
import 'data/demo_absence.dart';
import 'data/demo_breaker.dart';
import 'data/demo_holidays.dart';
import 'data/demo_timetable.dart';
import 'data/demo_users.dart';
/// Single source of truth for the MarianumConnect demo responses. The demo
/// interceptor asks this for the body of any MC request. Read endpoints reuse
@@ -32,8 +34,16 @@ class DemoMarianumConnect {
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search':
return DemoUsers.search(query['q']?.toString() ?? '')
.map((e) => e.toJson())
.toList();
case 'breaker':
return DemoBreaker.none().toJson();
case 'absence/classes':
return DemoAbsence.classes();
case 'absence/prefill':
return DemoAbsence.prefill().toJson();
case 'timetable/elements/teachers':
case 'timetable/elements/students':
case 'timetable/elements/classes':
+90
View File
@@ -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;
}
}
+3 -3
View File
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart';
import '../api_error.dart';
import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart';
import 'app_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).
AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode;
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
@@ -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,
);
}
+53
View File
@@ -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 '../../../model/account_data.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via
@@ -18,21 +19,29 @@ class GetAppPassword {
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
/// Returns the freshly minted app password. Throws on any transport or
/// protocol error — callers treat push registration as best-effort and swallow
/// failures.
/// protocol error — a 401 becomes an [AuthException], which the login flow
/// reads as "Nextcloud rejects the password" (two-factor authentication or
/// password mismatch) and answers with the interactive Login Flow v2.
Future<String> run() async {
final response = await _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
},
);
const label = 'Nextcloud getapppassword';
final response = (await sendGuarded(
label,
() => _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
},
),
))!;
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('getapppassword HTTP ${response.statusCode}');
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -38,9 +37,6 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return AutocompleteResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
}
}
@@ -1,18 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces:
@@ -42,30 +37,17 @@ Future<http.Response> _send(
) async {
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await perform(uri, headers);
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
}
final response = (await sendGuarded(
'Cloud $uri',
() => perform(uri, headers),
))!;
final status = response.statusCode;
if (status >= 200 && status < 300) return response;
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Cloud $uri -> HTTP $status'
: 'Cloud $uri -> HTTP $status body=$preview';
final detail = httpErrorDetail('Cloud $uri', response.body, status);
log(detail);
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
throwForStatus(status, detail);
}
class SetUserAvatar {
@@ -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,
);
}
}
+7
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart';
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
class NextcloudOcs {
NextcloudOcs._();
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
/// every response nests its `meta`/`data` under).
static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -28,9 +27,7 @@ class SearchFiles {
'Files search failed with ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data);
}
}
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
@override
GetChatResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 200,
// Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50,
),
).run(),
fromJson: GetChatResponse.fromJson,
@@ -0,0 +1,56 @@
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
/// when there are no older messages left — a normal "start of chat" outcome here.
/// `setReadMarker=off` so paging into history never moves the read cursor.
class GetChatHistory {
final String chatToken;
final int lastKnownMessageId;
final int limit;
GetChatHistory({
required this.chatToken,
required this.lastKnownMessageId,
required this.limit,
});
/// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off,
limit: limit,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
);
}
}
@@ -1,11 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -41,24 +37,17 @@ class LongPollChat {
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
@@ -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';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'create_room_params.dart';
import 'create_room_response.dart';
class CreateRoom extends TalkApi {
class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoomParams params;
CreateRoom(this.params) : super('v4/room', params);
@override
Null assemble(String raw) => null;
CreateRoomResponse assemble(String raw) =>
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
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 '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_participants_response.dart';
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
GetParticipants(this.token) : super('v4/room/$token/participants', null);
@override
GetParticipantsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetParticipantsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_poll_state_response.dart';
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -4,6 +4,18 @@ import '../../../api_response.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)
class GetPollStateResponse extends ApiResponse {
GetPollStateResponseObject data;
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetPollStateResponseObjectFromJson(json);
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';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_reactions_response.dart';
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
: super('v1/reaction/$chatToken/$messageId', null);
@override
GetReactionsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetReactionsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
@@ -0,0 +1,40 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_shared_items_response.dart';
/// Fetches the messages that shared an item of a given [objectType] in a chat
/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using
/// the `X-Chat-Last-Given` response header (see
/// [GetSharedItemsResponse.lastGivenMessageId]).
///
/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`,
/// `deckcard`, `recording`, `other`.
class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
GetSharedItems(
String token, {
required String objectType,
int limit = 20,
int? lastKnownMessageId,
}) : super(
'v1/chat/$token/share',
null,
getParameters: {
'objectType': objectType,
'limit': limit,
'lastKnownMessageId': ?lastKnownMessageId,
},
);
@override
GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -0,0 +1,28 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_shared_items_overview_response.dart';
/// Fetches the latest shared items of every type at once (Talk's
/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to
/// show and to seed their first page. [limit] caps the items returned per type.
class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
GetSharedItemsOverview(String token, {int limit = 20})
: super(
'v1/chat/$token/share/overview',
null,
getParameters: {'limit': limit},
);
@override
GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -0,0 +1,32 @@
import '../../../api_response.dart';
import '../chat/get_chat_response.dart';
/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared
/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`,
/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the
/// message structure.
class GetSharedItemsOverviewResponse extends ApiResponse {
final Map<String, List<GetChatResponseObject>> itemsByType;
GetSharedItemsOverviewResponse(this.itemsByType);
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
final data = ocs['data'];
final result = <String, List<GetChatResponseObject>>{};
if (data is Map<String, dynamic>) {
for (final entry in data.entries) {
final value = entry.value;
final raw = switch (value) {
List<dynamic> list => list,
Map<dynamic, dynamic> map => map.values,
_ => const <dynamic>[],
};
result[entry.key] = raw
.whereType<Map<String, dynamic>>()
.map(GetChatResponseObject.fromJson)
.toList();
}
}
return GetSharedItemsOverviewResponse(result);
}
}
@@ -0,0 +1,35 @@
import '../../../api_response.dart';
import '../chat/get_chat_response.dart';
/// Response of Talk's shared-items endpoints. The message objects carry the
/// same structure as regular chat messages, so we reuse [GetChatResponseObject]
/// (the shared file lives in `messageParameters['file']`).
///
/// The server returns `data` either as a list or as a message-id-keyed map
/// depending on version; [fromOcs] normalises both to a list.
class GetSharedItemsResponse extends ApiResponse {
final List<GetChatResponseObject> items;
GetSharedItemsResponse(this.items);
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
final data = ocs['data'];
final raw = switch (data) {
List<dynamic> list => list,
Map<dynamic, dynamic> map => map.values,
_ => const <dynamic>[],
};
final items = raw
.whereType<Map<String, dynamic>>()
.map(GetChatResponseObject.fromJson)
.toList();
return GetSharedItemsResponse(items);
}
/// Offset for the next page, taken from the `X-Chat-Last-Given` header.
/// Null when the header is absent (older server) — treat that as "stop".
int? get lastGivenMessageId {
final value = headers?['x-chat-last-given'];
return value == null ? null : int.tryParse(value);
}
}
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_room_params.dart';
import 'get_room_response.dart';
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override
GetRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
+13 -40
View File
@@ -1,29 +1,20 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../api_params.dart';
import '../../api_request.dart';
import '../../api_response.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
enum TalkApiMethod { get, post, put, delete }
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
abstract class TalkApi<T extends ApiResponse?> {
String path;
ApiParams? body;
Map<String, String>? headers;
Map<String, dynamic>? getParameters;
http.Response? response;
TalkApi(this.path, this.body, {this.headers, this.getParameters});
Future<http.Response>? request(
@@ -40,22 +31,15 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
);
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
final http.Response data;
try {
final raw = await request(endpoint, body, mergedHeaders);
if (raw == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
final data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
);
if (data == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
final status = data.statusCode;
@@ -63,20 +47,9 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
// Talk's OCS errors carry the real reason in the body (expired session,
// removed participant, ...); include a trimmed preview so the dialog and
// logs surface the cause instead of just the bare status code.
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Talk $endpoint -> HTTP $status'
: 'Talk $endpoint -> HTTP $status body=$preview';
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
log(detail);
if (status == 401) {
throw AuthException.unauthorized(technicalDetails: detail);
}
if (status == 403) {
throw AuthException.forbidden(technicalDetails: detail);
}
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
throwForStatus(status, detail);
}
try {
@@ -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};
+19 -7
View File
@@ -2,24 +2,36 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../api_request.dart';
import '../../api_response.dart';
abstract class WebdavApi<T> extends ApiRequest {
abstract class WebdavApi<T> {
T genericParams;
WebdavApi(this.genericParams) {
establishWebdavConnection();
}
WebdavApi(this.genericParams);
Future<ApiResponse> run();
static Future<WebDavClient> webdav = establishWebdavConnection();
static Future<WebDavClient>? _webdav;
static String? _webdavSecret;
/// Shared WebDAV client. Rebuilt whenever the effective Nextcloud secret
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> get webdav {
final secret = AccountData().getNextcloudSecret();
if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret;
_webdav = establishWebdavConnection();
}
return _webdav!;
}
static Future<WebDavClient> establishWebdavConnection() async =>
NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'),
password: AccountData().getPassword(),
// App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see AccountData.usesLoginFlow).
password: AccountData().getNextcloudSecret(),
loginName: AccountData().getUsername(),
).webdav;
@@ -36,7 +36,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
// Token mitschicken statt ein eigenes 401 einzufangen.
final pending = _pendingReLogin;
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) {
options.headers['Authorization'] = 'Bearer $token';
}
@@ -1,5 +1,13 @@
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
/// separate from `AccountData` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
@@ -11,7 +19,7 @@ class MarianumConnectTokenStorage {
final FlutterSecureStorage _storage;
const MarianumConnectTokenStorage([
this._storage = const FlutterSecureStorage(),
this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions),
]);
Future<String?> readToken() => _storage.read(key: _tokenKey);
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
class MarianumConnectApi {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 20);
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
static final Dio _instance = _build();
static Dio dio() => _instance;
/// A fresh dio with the standard JSON options but no interceptors — used by
/// the auth queries (login/verify) that must bypass the bearer/demo
/// interceptors to avoid a re-auth loop.
static Dio plainDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _plainReceiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
static Dio _build() {
final dio = Dio(
BaseOptions(
@@ -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,61 +1,43 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'auth_login_response.dart';
/// 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*
/// run through the shared dio instance — that one has the interceptor, which
/// would attempt to re-auth us into a loop if our credentials are wrong.
class AuthLogin {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
class AuthLogin extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogin({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String username,
required String password,
required String tokenName,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('auth/login'),
data: {
'username': username,
'password': password,
'tokenName': tokenName,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/login'),
data: {
'username': username,
'password': password,
'tokenName': tokenName,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
});
}
@@ -1,26 +1,23 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// 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
/// an unusable session.
class AuthLogout {
class AuthLogout extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogout({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio = dio ?? MarianumConnectApi.dio();
super.dio,
}) : _tokenStorage = tokenStorage;
Future<void> run() async {
try {
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
await dio.post<void>(endpoint('auth/logout'));
} on DioException catch (_) {
// ignore — local clear below still happens
} finally {
@@ -2,8 +2,8 @@ import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
/// 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
@@ -12,29 +12,15 @@ import '../../marianumconnect_endpoint.dart';
///
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
/// and obscure a real 401 with a silent re-login.
class AuthVerify {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
class AuthVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] on 401 (credentials no longer match the token's
/// user, token missing, or token rejected), other [AppException]s on
@@ -49,14 +35,12 @@ class AuthVerify {
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('auth/verify'),
return guard(() async {
await dio.post<void>(
endpoint('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
});
}
}
@@ -1,26 +1,12 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_breakers_response.dart';
/// 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
/// required, so this also works before login (e.g. to block the whole app).
class GetBreakers {
final Dio _dio;
class GetBreakers extends MarianumConnectQuery {
GetBreakers({super.dio});
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
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);
}
}
Future<GetBreakersResponse> run() =>
getObject('breaker', GetBreakersResponse.fromJson);
}
@@ -55,6 +55,8 @@ enum BreakerArea {
files,
@JsonValue('NEWS')
news,
@JsonValue('TICKER')
ticker,
@JsonValue('ROOMPLAN')
roomPlan,
@JsonValue('GRADES')
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
BreakerArea.talk: 'TALK',
BreakerArea.files: 'FILES',
BreakerArea.news: 'NEWS',
BreakerArea.ticker: 'TICKER',
BreakerArea.roomPlan: 'ROOMPLAN',
BreakerArea.grades: 'GRADES',
BreakerArea.holidays: 'HOLIDAYS',
@@ -1,26 +1,12 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_capabilities_response.dart';
/// Fetches the current user's mobile capability flags from
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
/// so the bearer token is attached automatically.
class GetCapabilities {
final Dio _dio;
class GetCapabilities extends MarianumConnectQuery {
GetCapabilities({super.dio});
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
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);
}
}
Future<CapabilitiesResponse> run() =>
getObject('me/capabilities', CapabilitiesResponse.fromJson);
}
@@ -16,9 +16,23 @@ class CapabilitiesResponse {
@JsonKey(defaultValue: false)
final bool pushNotifications;
/// How many days into the past/future the user may view the timetable.
/// `null` (absent) means unlimited — the school year alone governs. The
/// backend widens both to at least cover the current MonSun week.
final int? timetablePastDays;
final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
/// predates the field or has no LDAP record for the user.
final String? userType;
CapabilitiesResponse({
required this.viewForeignTimetables,
required this.pushNotifications,
this.timetablePastDays,
this.timetableFutureDays,
this.userType,
});
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>

Some files were not shown because too many files have changed in this diff Show More