174 changed files with 13344 additions and 1725 deletions
+16 -6
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
## Stack ## Stack
@@ -16,7 +16,10 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine ├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens ├── view/ Screens
@@ -51,6 +54,14 @@ lib/
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert neue Push-Ziele nur dort ergänzen.
## Build / Run ## Build / Run
```bash ```bash
@@ -65,13 +76,12 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | | MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). `nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
## Tests ## Tests
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
+17
View File
@@ -41,6 +41,23 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
</intent-filter> </intent-filter>
<!-- Guardian login mail link (App Link). Verified via
/.well-known/assetlinks.json on both hosts. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="connect.marianum-fulda.de" />
<data android:host="connect-beta.marianum-fulda.de" />
<data android:path="/app/guardian-login" />
</intent-filter>
<!-- The app routes links itself (GuardianLinkListener); Flutter's
built-in handling would push the path as a named route onto a
MaterialApp that only has `home`. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+3 -1
View File
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async { Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil( final loginVisible = await _pumpUntil(
tester, tester,
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-audience-school')),
); );
if (!loginVisible) { if (!loginVisible) {
_log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login'); _log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login');
return; return;
} }
await tester.tap(find.byKey(const Key('login-audience-school')));
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
await tester.enterText( await tester.enterText(
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-username-field')),
'demo@screenshots', 'demo@screenshots',
+2
View File
@@ -37,6 +37,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
+5
View File
@@ -4,6 +4,11 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:connect.marianum-fulda.de</string>
<string>applinks:connect-beta.marianum-fulda.de</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
+17
View File
@@ -0,0 +1,17 @@
import '../session/session.dart';
/// Backend identity a feature needs. Modules and settings sections declare
/// these; anything whose requirements the session does not meet is hidden.
enum AccessRequirement {
nextcloud,
guardian;
bool isMetBy(Session? session) => switch (this) {
AccessRequirement.nextcloud => session?.nextcloud != null,
AccessRequirement.guardian => session is GuardianSession,
};
}
extension AccessRequirements on Set<AccessRequirement> {
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
}
+20
View File
@@ -0,0 +1,20 @@
/// Account role as reported by MarianumConnect. Only used for display and for
/// deriving policies; features gate on capabilities and requirements instead
/// of comparing roles.
enum UserRole {
student,
teacher,
staff,
parent,
unknown;
/// Unknown or missing values map to [unknown] so a new server-side role
/// never breaks older app versions.
static UserRole parse(String? wire) => switch (wire) {
'STUDENT' => UserRole.student,
'TEACHER' => UserRole.teacher,
'STAFF' => UserRole.staff,
'PARENT' => UserRole.parent,
_ => UserRole.unknown,
};
}
+23
View File
@@ -1,5 +1,7 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart'; import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart'; import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../demo_persona.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the /// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable /// demo persona sees every feature (incl. push) as available and the timetable
@@ -17,6 +19,27 @@ class DemoCapabilities {
userType: 'STUDENT', userType: 'STUDENT',
loaded: true, loaded: true,
); );
/// Guardian persona with two children, so the child switcher is visible.
static CapabilitiesState guardianState() => const CapabilitiesState(
pushNotifications: true,
userType: 'PARENT',
children: [
GuardianChild(
id: 'demo-child-1',
firstName: DemoPersona.studentFirstName,
lastName: 'Hoffmann',
className: DemoPersona.className,
),
GuardianChild(
id: 'demo-child-2',
firstName: 'Jonas',
lastName: 'Hoffmann',
className: '6a',
),
],
loaded: true,
);
} }
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive /// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
+273
View File
@@ -0,0 +1,273 @@
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../demo_persona.dart';
/// Demo inbox of the guardian persona: one letter per variant (information,
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
/// Child ids match [DemoCapabilities.guardianState].
class DemoParentLetters {
const DemoParentLetters._();
static const _lena = 'demo-child-1';
static const _jonas = 'demo-child-2';
static final Set<String> _readInSession = {};
static void markRead(String letterId) => _readInSession.add(letterId);
static ParentLetterListResponse list() {
final items = [for (final letter in _letters()) letter.summary];
return ParentLetterListResponse(
items: items,
unreadCount: items.where((letter) => !letter.read).length,
openCount: items
.where((letter) => letter.status == ParentLetterStatus.open)
.length,
);
}
static ParentLetterDetail detail(String letterId) {
final letters = _letters();
return letters.firstWhere(
(letter) => letter.summary.id == letterId,
orElse: () => letters.first,
);
}
static ParentLetterPerson _teacher(int index) =>
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
static const _participation = ParentLetterField(
id: 'participation',
type: ParentLetterFieldType.singleChoice,
label: 'Nimmt Ihr Kind teil?',
isRequired: true,
options: [
ParentLetterOption(id: 'yes', label: 'Ja'),
ParentLetterOption(id: 'no', label: 'Nein'),
],
);
static List<ParentLetterDetail> _letters() {
final now = DateTime.now();
ParentLetterDetail letter({
required String id,
required String subject,
required ParentLetterPerson sender,
required Duration age,
required String body,
required List<String> childIds,
bool read = true,
ParentLetterStatus status = ParentLetterStatus.info,
ParentLetterRequest? request,
List<ParentLetterChildState> children = const [],
List<ParentLetterAttachment> attachments = const [],
ParentLetterThread thread = const ParentLetterThread(enabled: true),
}) => ParentLetterDetail(
summary: ParentLetterSummary(
id: id,
subject: subject,
preview: body.replaceAll('\n', ' ').trim(),
sender: sender,
sentAt: now.subtract(age),
read: read || _readInSession.contains(id),
childIds: childIds,
attachmentCount: attachments.length,
status: status,
deadline: request?.deadline,
),
content: ParentLetterContent(
body: body,
attachments: attachments,
request: request,
children: children,
thread: thread,
),
);
return [
letter(
id: 'demo-letter-hike',
subject: 'Wandertag der ${DemoPersona.className}',
sender: _teacher(0),
age: const Duration(hours: 2),
read: false,
childIds: const [_lena],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
'Viele Grüße\n${DemoPersona.teachers[0].name}',
attachments: const [
ParentLetterAttachment(
id: 'demo-attachment-hike',
fileName: 'Wandertag.pdf',
mimeType: 'application/pdf',
size: 48211,
),
],
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.add(const Duration(days: 10)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-parents-evening',
subject: 'Einladung zum Elternabend der 6a',
sender: _teacher(1),
age: const Duration(days: 1),
read: false,
childIds: const [_jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
request: const ParentLetterRequest(),
children: const [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-school-festival',
subject: 'Schulfest: Wer kommt mit?',
sender: _teacher(5),
age: const Duration(days: 3),
childIds: const [_lena, _jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
'https://www.marianum-fulda.de',
request: ParentLetterRequest(
fields: const [_participation],
deadline: now.add(const Duration(days: 5)),
),
children: [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.done,
editable: true,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 2)),
respondedBy: const ParentLetterPerson(self: true),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
),
),
const ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-reports',
subject: 'Zeugnisausgabe und Unterrichtsschluss',
sender: _teacher(3),
age: const Duration(days: 7),
childIds: const [_lena, _jonas],
body:
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
'Busse fahren entsprechend früher.',
thread: ParentLetterThread(
enabled: true,
messages: [
ParentLetterThreadMessage(
id: 'demo-thread-1',
author: const ParentLetterPerson(self: true),
body: 'Gilt das auch für die Nachmittagsbetreuung?',
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
),
ParentLetterThreadMessage(
id: 'demo-thread-2',
author: _teacher(3),
body:
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
'Bedarf melden Sie sich bitte im Sekretariat.',
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
),
],
),
),
letter(
id: 'demo-letter-swimming',
subject: 'Einverständnis Schwimmunterricht',
sender: _teacher(6),
age: const Duration(days: 21),
childIds: const [_jonas],
status: ParentLetterStatus.done,
body:
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
'Einverständnis.',
request: const ParentLetterRequest(
fields: [_participation],
signatureRequired: true,
),
children: [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.done,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 20)),
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
signed: true,
),
),
],
thread: const ParentLetterThread(),
),
letter(
id: 'demo-letter-ski-trip',
subject: 'Anmeldung zur Skifreizeit',
sender: _teacher(6),
age: const Duration(days: 35),
childIds: const [_lena],
status: ParentLetterStatus.expired,
body:
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.subtract(const Duration(days: 14)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.expired,
),
],
thread: const ParentLetterThread(),
),
];
}
}
+4 -3
View File
@@ -1,4 +1,4 @@
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart'; import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart'; import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart'; import '../demo_persona.dart';
@@ -204,7 +204,8 @@ class DemoTalk {
id: base + 2, id: base + 2,
token: token, token: token,
ago: const Duration(days: 1, hours: 6), ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', message:
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
), ),
_msg( _msg(
id: base + 3, id: base + 3,
@@ -366,7 +367,7 @@ class DemoTalk {
}) => _msg( }) => _msg(
id: id, id: id,
token: token, token: token,
actor: AccountData().getUsername(), actor: SessionManager().requireNextcloud().username,
display: DemoPersona.studentName, display: DemoPersona.studentName,
ago: ago, ago: ago,
message: message, message: message,
+11 -3
View File
@@ -1,4 +1,4 @@
import '../../model/account_data.dart'; import '../../session/session_manager.dart';
/// Central switch for the client-side demo mode. /// Central switch for the client-side demo mode.
/// ///
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
/// Play reviewers and automated screenshot runs see a fully populated app /// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency. /// without a real account and without any network dependency.
/// ///
/// The flag is persisted through [AccountData.isDemo], so a demo session /// The flag is persisted through [Session.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds /// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build. /// too — reviewers run the shipped release build.
class DemoMode { class DemoMode {
@@ -22,6 +22,14 @@ class DemoMode {
static bool matches(String username) => static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix); username.trim().toLowerCase().startsWith(usernamePrefix);
/// Guardian logins are passwordless, so reviewers need a fixed address that
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
/// real e-mail address could start with.
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
static bool matchesGuardian(String email) =>
email.trim().toLowerCase() == guardianEmail;
/// True while the active session is a demo session. /// True while the active session is a demo session.
static bool get active => AccountData().isDemo; static bool get active => SessionManager().isDemo;
} }
+8 -1
View File
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../session/session.dart';
import '../api_error.dart'; import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final preview = previewBody(error.body); final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; final detail = preview.isEmpty
? 'HTTP $status'
: 'HTTP $status body=$preview';
switch (status) { switch (status) {
case 401: case 401:
return AuthException.unauthorized(technicalDetails: detail); return AuthException.unauthorized(technicalDetails: detail);
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is AppException) return error.userMessage; if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage; if (error is TalkError) return TalkException(error).userMessage;
if (error is NextcloudUnavailableException) {
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
}
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
bool errorAllowsRetry(Object? error) { bool errorAllowsRetry(Object? error) {
if (error == null) return true; if (error == null) return true;
if (error is AppException) return error.allowRetry; if (error is AppException) return error.allowRetry;
if (error is NextcloudUnavailableException) return false;
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry; if (mapped != null) return mapped.allowRetry;
@@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via /// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then /// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]), /// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
/// which is what the push-v2 registration binds to. /// which is what the push-v2 registration binds to.
/// ///
/// Must authenticate with the *real* password — an app password cannot mint /// Must authenticate with the *real* password — an app password cannot mint
@@ -33,7 +33,9 @@ class GetAppPassword {
// Deliberately NOT the shared Authorization value: that one prefers // Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one — // the app password, but an app password cannot mint another one —
// this endpoint requires the real password. // this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(), 'Authorization': SessionManager()
.requireNextcloud()
.realPasswordBasicAuthHeader,
}, },
), ),
))!; ))!;
@@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
} }
Uri _userInfoUri() => Uri _userInfoUri() => NextcloudOcs.uri(
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); 'cloud/users/${SessionManager().requireNextcloud().username}',
);
Future<http.Response> _send( Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers) Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
perform,
Uri uri, Uri uri,
) async { ) async {
final headers = NextcloudOcs.headers(); final headers = NextcloudOcs.headers();
@@ -98,16 +98,13 @@ class GetUserInfo {
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>; final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
(root['ocs'] as Map<String, dynamic>)['data'] (root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
); );
} catch (e) { } catch (e) {
throw ParseException( throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
technicalDetails: 'Cloud $uri user info parse: $e',
);
} }
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
import '../../session/session_manager.dart';
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// TalkApi, AutocompleteApi, FileSharingApi. /// TalkApi, AutocompleteApi, FileSharingApi.
@@ -16,7 +16,7 @@ class NextcloudOcs {
static Map<String, String> headers() => { static Map<String, String> headers() => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(), 'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
+7 -7
View File
@@ -1,7 +1,7 @@
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../api_response.dart'; import '../../api_response.dart';
abstract class WebdavApi<T> { abstract class WebdavApi<T> {
@@ -18,7 +18,7 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps /// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials. /// authenticating with stale credentials.
static Future<WebDavClient> get webdav { static Future<WebDavClient> get webdav {
final secret = AccountData().getNextcloudSecret(); final secret = SessionManager().requireNextcloud().secret;
if (_webdav == null || _webdavSecret != secret) { if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret; _webdavSecret = secret;
_webdav = establishWebdavConnection(); _webdav = establishWebdavConnection();
@@ -30,13 +30,13 @@ abstract class WebdavApi<T> {
NextcloudClient( NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'), Uri.parse('https://${EndpointData().nextcloud().full()}'),
// App password preferred — with 2FA the real password is not accepted // App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see AccountData.usesLoginFlow). // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
password: AccountData().getNextcloudSecret(), password: SessionManager().requireNextcloud().secret,
loginName: AccountData().getUsername(), loginName: SessionManager().requireNextcloud().username,
).webdav; ).webdav;
/// Builds the WebDAV download URL without embedded credentials. Callers must /// Builds the WebDAV download URL without embedded credentials. Callers must
/// authenticate via the [AccountData.authHeaders] header instead. /// authenticate via the [NextcloudCredentials.authHeaders] header instead.
static String buildWebdavUrl() => static String buildWebdavUrl() =>
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
} }
@@ -1,12 +1,14 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../model/account_data.dart'; import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../queries/auth_login/auth_login.dart'; import '../queries/auth_login/auth_login.dart';
import 'device_token_name.dart'; import 'device_token_name.dart';
import 'token_storage.dart'; import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, /// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// re-logs in once with the credentials in [AccountData] before retrying. /// renews the token once before retrying. Only password accounts can renew
/// silently; passwordless accounts surface the 401.
class MarianumConnectAuthInterceptor extends Interceptor { class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried'; static const _retriedKey = 'mc_auth_retried';
@@ -64,6 +66,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
} }
final refreshed = await _attemptReLogin(); final refreshed = await _attemptReLogin();
if (!refreshed) { if (!refreshed) {
if (SessionManager().current is GuardianSession) {
SessionManager().reportUnauthorized();
}
handler.next(err); handler.next(err);
return; return;
} }
@@ -87,11 +92,12 @@ class MarianumConnectAuthInterceptor extends Interceptor {
} }
Future<bool> _performReLogin() async { Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false; final session = SessionManager().current;
if (session is! CredentialSession) return false;
try { try {
await _loginClient.run( await _loginClient.run(
username: AccountData().getUsername(), username: session.username,
password: AccountData().getPassword(), password: session.password,
tokenName: await DeviceTokenName.resolve(), tokenName: await DeviceTokenName.resolve(),
); );
return true; return true;
@@ -1,35 +1,46 @@
import 'dart:developer'; import 'dart:developer';
import '../../../model/account_data.dart'; import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart'; import '../../errors/auth_exception.dart';
import '../queries/auth_logout/auth_logout.dart'; import '../queries/auth_me/auth_me.dart';
import '../queries/auth_verify/auth_verify.dart'; import '../queries/auth_verify/auth_verify.dart';
import 'token_storage.dart';
/// Background credential probe a server-side password rotation forces a /// Credential probe. For password accounts a server-side password rotation
/// re-login on the next cold start even when the bearer token would still /// forces a re-login on the next cold start even when the bearer token would
/// be accepted. /// still be accepted; for guardians it confirms a rejected token before the
/// session is dropped.
class SessionValidator { class SessionValidator {
static Future<void> probeStored({ static Future<void> probeStored({
required Future<void> Function() onInvalidated, required Future<void> Function() onInvalidated,
}) async { }) async {
if (!AccountData().isPopulated()) return; final session = SessionManager().current;
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo // The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped here or its missing token would 401 into a logout. // session must be skipped or its missing token would 401 into a logout.
if (AccountData().isDemo) return; if (session == null || session.isDemo) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
try { try {
await AuthVerify().run(username: username, password: password); switch (session) {
case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password);
case GuardianSession():
await AuthMe().run();
}
} on AuthException catch (e) { } on AuthException catch (e) {
if (e.statusCode != 401) return; if (e.statusCode != 401) return;
log('MC: stored credentials rejected — forcing re-login'); log('MC: stored session rejected — forcing re-login');
await AuthLogout().run(); await SessionLifecycle.signOut(
await const MarianumConnectTokenStorage().clear(); notice: switch (session) {
await AccountData().removeData(); CredentialSession() =>
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
GuardianSession() =>
'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.',
},
);
await onInvalidated(); await onInvalidated();
} catch (e) { } catch (e) {
log('MC: background credential check failed (transient): $e'); log('MC: background session check failed (transient): $e');
} }
} }
} }
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background /// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device /// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws /// unlock following a reboot. The keychain default (`whenUnlocked`) throws
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
); );
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept /// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud /// separate from `SessionManager` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint. /// + MHSL still need them) while the MC token is short-lived and per-endpoint.
class MarianumConnectTokenStorage { class MarianumConnectTokenStorage {
static const _tokenKey = 'mc_bearer_token'; static const _tokenKey = 'mc_bearer_token';
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
Future<String?> readToken() => _storage.read(key: _tokenKey); Future<String?> readToken() => _storage.read(key: _tokenKey);
/// Request options carrying the stored token, for probes that bypass the
/// auth interceptor. Throws [AuthException] when no token is stored.
Future<Options> requireBearerOptions(String caller) async {
final token = await readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: '$caller: no bearer token in storage',
);
}
return Options(headers: {'Authorization': 'Bearer $token'});
}
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey); Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async { Future<DateTime?> readExpiresAt() async {
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of /// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites /// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message. /// that just want to render an error message.
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../errors/app_exception.dart';
import 'errors/marianumconnect_error.dart'; import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart'; import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart'; import 'marianumconnect_endpoint.dart';
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
try { try {
return await body(); return await body();
} on DioException catch (e) { } on DioException catch (e) {
throw mapMarianumConnectError(e); throw mapError(e);
} }
} }
/// The AppException a failed call surfaces as. Query families with domain
/// error codes override this instead of re-implementing [guard].
AppException mapError(DioException error) => mapMarianumConnectError(error);
/// GETs [path] and parses the JSON object body with [fromJson]. /// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>( Future<T> getObject<T>(
String path, String path,
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
.toList(); .toList();
}); });
/// GETs the raw bytes of [path] (files that need the bearer token).
Future<Uint8List> getBytes(String path) => guard(() async {
final response = await dio.get<List<int>>(
endpoint(path),
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data!;
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params. /// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) => String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -1,10 +1,30 @@
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'absence_prefill_response.dart'; import 'absence_prefill_response.dart';
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth). /// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
/// Guardians pass the [childId] the report is for; the identity then comes
/// from that child.
class AbsencePrefill extends MarianumConnectQuery { class AbsencePrefill extends MarianumConnectQuery {
AbsencePrefill({super.dio}); AbsencePrefill({super.dio});
Future<AbsencePrefillResponse> run() => Future<AbsencePrefillResponse> run({String? childId}) => getObject(
getObject('absence/prefill', AbsencePrefillResponse.fromJson); 'absence/prefill',
AbsencePrefillResponse.fromJson,
queryParameters: {'childId': ?childId},
);
}
/// The prefill of a guardian's report is missing a field the form cannot ask
/// for: name and class come from the child and are not editable there, so an
/// incomplete prefill would leave a locked, unsubmittable form behind.
class AbsencePrefillIncompleteException extends AppException {
const AbsencePrefillIncompleteException({super.technicalDetails})
: super(
userMessage:
'Für dieses Kind sind in der Schulverwaltung noch nicht alle '
'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. '
'Bitte wende dich an das Sekretariat.',
allowRetry: false,
);
} }
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`). /// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
/// Empty identity fields are backfilled from LDAP server-side; validation /// Empty identity fields are backfilled from LDAP server-side; validation
/// (all fields required, class must exist, no past start date, end >= start) /// (all fields required, class must exist, no past start date, end >= start)
/// also runs server-side and mirrors the client checks. /// also runs server-side and mirrors the client checks. For guardians the
/// server takes name and class from the child given by [childId].
class AbsenceSubmit extends MarianumConnectQuery { class AbsenceSubmit extends MarianumConnectQuery {
AbsenceSubmit({super.dio}); AbsenceSubmit({super.dio});
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
required DateTime absentUntil, required DateTime absentUntil,
required String phone, required String phone,
required String note, required String note,
String? childId,
}) => guard(() async { }) => guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('absence'), endpoint('absence'),
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
'absentUntil': isoDate(absentUntil), 'absentUntil': isoDate(absentUntil),
'phone': phone, 'phone': phone,
'note': note, 'note': note,
'childId': ?childId,
}, },
); );
}); });
@@ -0,0 +1,60 @@
import 'package:dio/dio.dart';
import '../../../../auth_link/pending_guardian_request.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'guardian_login_exception.dart';
class AuthGuardianRequestResponse {
final String requestId;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const AuthGuardianRequestResponse({
required this.requestId,
required this.expiresAt,
required this.resendAvailableAt,
required this.codeLength,
});
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
AuthGuardianRequestResponse(
requestId: json['requestId'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength:
json['codeLength'] as int? ??
PendingGuardianRequest.defaultCodeLength,
);
}
/// Starts a passwordless guardian login: the server mails a code and a link.
/// An unknown address is reported as such (`email_not_registered`) instead of
/// being answered like a known one — a deliberate trade: without it guardians
/// wait for a mail that never arrives. Registered addresses are therefore
/// enumerable; keep that in mind when changing the server contract.
class AuthGuardianRequest extends MarianumConnectQuery {
AuthGuardianRequest({Dio? dio})
: super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthGuardianRequestResponse> run({
required String email,
required String deviceChallenge,
required String tokenName,
}) async {
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/request'),
data: {
'email': email,
'deviceChallenge': deviceChallenge,
'tokenName': tokenName,
},
);
return AuthGuardianRequestResponse.fromJson(response.data!);
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e, verifying: false);
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
import 'guardian_login_exception.dart';
/// Completes a guardian login with the mailed code or link token and stores
/// the issued bearer token.
class AuthGuardianVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthGuardianVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String requestId,
required String deviceVerifier,
required String tokenName,
String? code,
String? linkToken,
}) async {
assert((code == null) != (linkToken == null));
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/verify'),
data: {
'requestId': requestId,
'deviceVerifier': deviceVerifier,
'tokenName': tokenName,
'code': ?code,
'linkToken': ?linkToken,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,120 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum GuardianLoginError {
invalidRequest,
emailNotRegistered,
accountDisabled,
invalidCode,
deviceMismatch,
requestConsumed,
requestExpired,
tooManyAttempts,
rateLimited,
unsupportedServer,
}
/// A rejected guardian login step with the reason the server reported.
class GuardianLoginException extends AppException {
final GuardianLoginError error;
final int? attemptsLeft;
const GuardianLoginException(
this.error, {
required super.userMessage,
this.attemptsLeft,
super.technicalDetails,
}) : super(allowRetry: false);
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
/// login reason become [GuardianLoginException]; everything else keeps the
/// generic MarianumConnect mapping (network, 5xx, …).
///
/// [verifying] false marks the e-mail step, where no code exists yet: a bare
/// 401 (reverse proxy, gateway) must not be reported as a wrong code there.
static AppException fromDio(DioException e, {bool verifying = true}) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500) {
return mapMarianumConnectError(e);
}
final (code, attemptsLeft) = _parseBody(response!.data);
final error = _errorFor(code, status, verifying: verifying);
if (error == null) return mapMarianumConnectError(e);
return GuardianLoginException(
error,
attemptsLeft: attemptsLeft,
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(
GuardianLoginError error, {
int? attemptsLeft,
}) => switch (error) {
GuardianLoginError.invalidRequest =>
'Bitte gib eine gültige E-Mail-Adresse ein.',
GuardianLoginError.emailNotRegistered =>
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
GuardianLoginError.accountDisabled =>
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
'das Sekretariat.',
GuardianLoginError.invalidCode =>
attemptsLeft == null
? 'Der Code ist falsch.'
: 'Der Code ist falsch. Noch $attemptsLeft '
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
GuardianLoginError.deviceMismatch =>
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
'Bitte gib stattdessen den Code aus der E-Mail ein.',
GuardianLoginError.requestConsumed =>
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
'Code an.',
GuardianLoginError.requestExpired =>
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
GuardianLoginError.tooManyAttempts =>
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
GuardianLoginError.rateLimited =>
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
'erneut.',
GuardianLoginError.unsupportedServer =>
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
'Bitte versuche es später erneut.',
};
static (String?, int?) _parseBody(Object? data) {
final attempts = data is Map ? data['attemptsLeft'] : null;
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
}
static GuardianLoginError? _errorFor(
String? code,
int status, {
required bool verifying,
}) => switch (code) {
'invalid_request' => GuardianLoginError.invalidRequest,
'email_not_registered' => GuardianLoginError.emailNotRegistered,
'account_disabled' => GuardianLoginError.accountDisabled,
'invalid_code' => GuardianLoginError.invalidCode,
'device_mismatch' => GuardianLoginError.deviceMismatch,
'request_consumed' => GuardianLoginError.requestConsumed,
'request_expired' => GuardianLoginError.requestExpired,
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
_ => switch (status) {
401 when verifying => GuardianLoginError.invalidCode,
// The server names an unknown address explicitly
// (`email_not_registered`); a bare 404/405 means the endpoint itself
// is missing, i.e. a server version without guardian login.
404 || 405 => GuardianLoginError.unsupportedServer,
409 => GuardianLoginError.requestConsumed,
410 => GuardianLoginError.requestExpired,
429 => GuardianLoginError.rateLimited,
_ => null,
},
};
}
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token is still accepted. Used for accounts
/// without a password (guardians), whose token cannot be renewed silently.
///
/// Bypasses the shared dio singleton so the auth interceptor does not react
/// to the 401 this probe is meant to observe.
class AuthMe extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthMe({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] when the token is missing or rejected.
Future<void> run() async {
final options = await _tokenStorage.requireBearerOptions('AuthMe');
return guard(() async {
await dio.get<void>(endpoint('auth/me'), options: options);
});
}
}
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
required String username, required String username,
required String password, required String password,
}) async { }) async {
final token = await _tokenStorage.readToken(); final options = await _tokenStorage.requireBearerOptions('AuthVerify');
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
return guard(() async { return guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('auth/verify'), endpoint('auth/verify'),
data: {'username': username, 'password': password}, data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}), options: options,
); );
}); });
} }
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'guardian_child.dart';
part 'get_capabilities_response.g.dart'; part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend /// Slimmed-down capability flags the mobile UI gates features on. The backend
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
final int? timetableFutureDays; final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend /// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
/// predates the field or has no LDAP record for the user. /// backend predates the field or has no record for the user.
final String? userType; final String? userType;
/// Students linked to a guardian account; empty for everyone else.
@JsonKey(defaultValue: <GuardianChild>[])
final List<GuardianChild> children;
CapabilitiesResponse({ CapabilitiesResponse({
required this.viewForeignTimetables, required this.viewForeignTimetables,
required this.pushNotifications, required this.pushNotifications,
this.timetablePastDays, this.timetablePastDays,
this.timetableFutureDays, this.timetableFutureDays,
this.userType, this.userType,
this.children = const [],
}); });
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) => factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?, userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
'timetablePastDays': instance.timetablePastDays, 'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays, 'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType, 'userType': instance.userType,
'children': instance.children,
}; };
@@ -0,0 +1,23 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'guardian_child.freezed.dart';
part 'guardian_child.g.dart';
/// A student linked to the signed-in guardian. [id] is an opaque server id,
/// not a WebUntis id.
@freezed
abstract class GuardianChild with _$GuardianChild {
const GuardianChild._();
const factory GuardianChild({
required String id,
required String firstName,
required String lastName,
@Default('') String className,
}) = _GuardianChild;
factory GuardianChild.fromJson(Map<String, Object?> json) =>
_$GuardianChildFromJson(json);
String get displayName => '$firstName $lastName'.trim();
}
@@ -0,0 +1,294 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'guardian_child.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GuardianChild {
String get id; String get firstName; String get lastName; String get className;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
/// Serializes this GuardianChild to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as GuardianChild;
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as GuardianChild;
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className);
}
@override
String toString() {
final _this = this as GuardianChild;
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})';
}
}
/// @nodoc
abstract mixin class $GuardianChildCopyWith<$Res> {
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
@useResult
$Res call({
String id, String firstName, String lastName, String className
});
}
/// @nodoc
class _$GuardianChildCopyWithImpl<$Res>
implements $GuardianChildCopyWith<$Res> {
_$GuardianChildCopyWithImpl(this._self, this._then);
final GuardianChild _self;
final $Res Function(GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
return _then(GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [GuardianChild].
extension GuardianChildPatterns on GuardianChild {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GuardianChild value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GuardianChild value) $default,){
final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GuardianChild value)? $default,){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className) $default,) {final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className)? $default,) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _GuardianChild extends GuardianChild {
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._();
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
@override final String id;
@override final String firstName;
@override final String lastName;
@override@JsonKey() final String className;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$GuardianChildToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,id,firstName,lastName,className);
}
@override
String toString() {
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)';
}
}
/// @nodoc
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
@override @useResult
$Res call({
String id, String firstName, String lastName, String className
});
}
/// @nodoc
class __$GuardianChildCopyWithImpl<$Res>
implements _$GuardianChildCopyWith<$Res> {
__$GuardianChildCopyWithImpl(this._self, this._then);
final _GuardianChild _self;
final $Res Function(_GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
return _then(_GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'guardian_child.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
_GuardianChild(
id: json['id'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
className: json['className'] as String? ?? '',
);
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
};
@@ -1,7 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from /// Downloads the raw PDF bytes of a Marianum Message from
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
GetNewsletterFile(this.id, {super.dio}); GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async { Future<Uint8List> run() =>
final response = await dio.get<List<int>>( getBytes('newsletter/${Uri.encodeComponent(id)}/file');
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
} }
@@ -1,7 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from /// Downloads the raw bytes of a PROXIED_FILE ticker page from
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
GetTickerPageFile(this.slug, {super.dio}); GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async { Future<Uint8List> run() =>
final response = await dio.get<List<int>>( getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
} }
@@ -0,0 +1,9 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
class GetParentLetter extends ParentLetterQuery {
GetParentLetter({super.dio});
Future<ParentLetterDetail> run(String letterId) =>
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
}
@@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'parent_letter_query.dart';
class GetParentLetterAttachment extends ParentLetterQuery {
GetParentLetterAttachment({super.dio});
Future<Uint8List> run({
required String letterId,
required String attachmentId,
}) => getBytes(
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
);
}
@@ -0,0 +1,16 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `GET parent-letters`: the guardian's inbox across all children, newest
/// first. [before] continues after the given letter (keyset paging).
class GetParentLetters extends ParentLetterQuery {
static const int pageSize = 50;
GetParentLetters({super.dio});
Future<ParentLetterListResponse> run({String? before}) => getObject(
'parent-letters',
ParentLetterListResponse.fromJson,
queryParameters: {'limit': pageSize, 'before': ?before},
);
}
@@ -0,0 +1,11 @@
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
/// for this guardian. Idempotent.
class MarkParentLetterRead extends ParentLetterQuery {
MarkParentLetterRead({super.dio});
Future<void> run(String letterId) => guard(() async {
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
});
}
@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum ParentLetterError {
guardianRequired,
letterNotFound,
childNotFound,
attachmentNotFound,
invalidAnswers,
signatureRequired,
responseFinal,
deadlinePassed,
threadDisabled,
invalidRequest,
}
/// A parent-letter call the server rejected with a domain reason.
class ParentLetterException extends AppException {
final ParentLetterError error;
const ParentLetterException(
this.error, {
required super.userMessage,
super.technicalDetails,
}) : super(allowRetry: false);
/// Only 4xx answers with a parent-letter reason become a
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
/// generic MarianumConnect mapping.
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500 || status == 401) {
return mapMarianumConnectError(e);
}
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
if (error == null) return mapMarianumConnectError(e);
return ParentLetterException(
error,
userMessage: messageFor(error),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(ParentLetterError error) => switch (error) {
ParentLetterError.guardianRequired =>
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
ParentLetterError.letterNotFound =>
'Dieser Elternbrief ist nicht mehr verfügbar.',
ParentLetterError.childNotFound =>
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
ParentLetterError.attachmentNotFound =>
'Der Anhang ist nicht mehr verfügbar.',
ParentLetterError.invalidAnswers =>
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
ParentLetterError.signatureRequired =>
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
ParentLetterError.responseFinal =>
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
'geändert werden.',
ParentLetterError.deadlinePassed =>
'Die Frist für diese Rückmeldung ist abgelaufen.',
ParentLetterError.threadDisabled =>
'Auf diesen Elternbrief kann nicht geantwortet werden.',
ParentLetterError.invalidRequest =>
'Die Anfrage konnte nicht verarbeitet werden.',
};
static ParentLetterError? _errorFor(String? code, int status) =>
switch (code) {
'guardian_required' => ParentLetterError.guardianRequired,
'letter_not_found' => ParentLetterError.letterNotFound,
'child_not_found' => ParentLetterError.childNotFound,
'attachment_not_found' => ParentLetterError.attachmentNotFound,
'invalid_answers' => ParentLetterError.invalidAnswers,
'signature_required' => ParentLetterError.signatureRequired,
'response_final' => ParentLetterError.responseFinal,
'deadline_passed' => ParentLetterError.deadlinePassed,
'thread_disabled' => ParentLetterError.threadDisabled,
'invalid_request' => ParentLetterError.invalidRequest,
_ => switch (status) {
404 => ParentLetterError.letterNotFound,
409 => ParentLetterError.responseFinal,
410 => ParentLetterError.deadlinePassed,
_ => null,
},
};
}
@@ -0,0 +1,226 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'parent_letter_models.freezed.dart';
part 'parent_letter_models.g.dart';
enum ParentLetterStatus { info, open, done, expired }
enum ParentLetterFieldType {
@JsonValue('single_choice')
singleChoice,
unknown,
}
/// Sender, thread author or responding guardian. [self] marks the signed-in
/// guardian.
@freezed
abstract class ParentLetterPerson with _$ParentLetterPerson {
const factory ParentLetterPerson({
@Default('') String displayName,
@Default(false) bool self,
}) = _ParentLetterPerson;
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
_$ParentLetterPersonFromJson(json);
}
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
/// child ids from `me/capabilities`.
@freezed
abstract class ParentLetterSummary with _$ParentLetterSummary {
const factory ParentLetterSummary({
required String id,
@Default('') String subject,
@Default('') String preview,
@Default(ParentLetterPerson()) ParentLetterPerson sender,
required DateTime sentAt,
DateTime? editedAt,
@Default(true) bool read,
@Default([]) List<String> childIds,
@Default(0) int attachmentCount,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
DateTime? deadline,
}) = _ParentLetterSummary;
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
_$ParentLetterSummaryFromJson(json);
}
@freezed
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
const factory ParentLetterListResponse({
@Default([]) List<ParentLetterSummary> items,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLetterListResponse;
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterListResponseFromJson(json);
}
@freezed
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
const factory ParentLetterAttachment({
required String id,
@Default('') String fileName,
@Default('') String mimeType,
@Default(0) int size,
}) = _ParentLetterAttachment;
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAttachmentFromJson(json);
}
@freezed
abstract class ParentLetterOption with _$ParentLetterOption {
const factory ParentLetterOption({
required String id,
@Default('') String label,
}) = _ParentLetterOption;
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
_$ParentLetterOptionFromJson(json);
}
@freezed
abstract class ParentLetterField with _$ParentLetterField {
const factory ParentLetterField({
required String id,
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
@Default(ParentLetterFieldType.unknown)
ParentLetterFieldType type,
@Default('') String label,
@JsonKey(name: 'required') @Default(false) bool isRequired,
@Default([]) List<ParentLetterOption> options,
}) = _ParentLetterField;
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
_$ParentLetterFieldFromJson(json);
}
/// What the sender asks for. No fields and no signature means a plain
/// acknowledgement.
@freezed
abstract class ParentLetterRequest with _$ParentLetterRequest {
const factory ParentLetterRequest({
@Default([]) List<ParentLetterField> fields,
@Default(false) bool signatureRequired,
DateTime? deadline,
}) = _ParentLetterRequest;
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
_$ParentLetterRequestFromJson(json);
}
@freezed
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
const factory ParentLetterAnswer({
required String fieldId,
@Default([]) List<String> optionIds,
String? text,
}) = _ParentLetterAnswer;
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAnswerFromJson(json);
}
@freezed
abstract class ParentLetterResponse with _$ParentLetterResponse {
const factory ParentLetterResponse({
DateTime? respondedAt,
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
@Default([]) List<ParentLetterAnswer> answers,
@Default(false) bool signed,
}) = _ParentLetterResponse;
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterResponseFromJson(json);
}
/// Per-child state of a letter. [editable] is the server's verdict on whether
/// this guardian may (re)submit right now.
@freezed
abstract class ParentLetterChildState with _$ParentLetterChildState {
const factory ParentLetterChildState({
required String childId,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
@Default(false) bool editable,
ParentLetterResponse? response,
}) = _ParentLetterChildState;
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterChildStateFromJson(json);
}
@freezed
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
const factory ParentLetterThreadMessage({
required String id,
@Default(ParentLetterPerson()) ParentLetterPerson author,
@Default('') String body,
required DateTime sentAt,
}) = _ParentLetterThreadMessage;
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadMessageFromJson(json);
}
@freezed
abstract class ParentLetterThread with _$ParentLetterThread {
const factory ParentLetterThread({
@Default(false) bool enabled,
@Default([]) List<ParentLetterThreadMessage> messages,
}) = _ParentLetterThread;
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadFromJson(json);
}
/// The detail-only part of `GET parent-letters/{id}`.
@freezed
abstract class ParentLetterContent with _$ParentLetterContent {
const factory ParentLetterContent({
@Default('') String body,
@Default([]) List<ParentLetterAttachment> attachments,
ParentLetterRequest? request,
@Default([]) List<ParentLetterChildState> children,
@Default(ParentLetterThread()) ParentLetterThread thread,
}) = _ParentLetterContent;
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
_$ParentLetterContentFromJson(json);
}
/// A full letter. The server sends the inbox fields and the detail fields in
/// one flat object; they are parsed into [summary] and [content] so the inbox
/// entry can be replaced from a detail answer without copying fields.
@immutable
class ParentLetterDetail {
final ParentLetterSummary summary;
final ParentLetterContent content;
const ParentLetterDetail({required this.summary, required this.content});
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
ParentLetterDetail(
summary: ParentLetterSummary.fromJson(json),
content: ParentLetterContent.fromJson(json),
);
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
@override
bool operator ==(Object other) =>
other is ParentLetterDetail &&
other.summary == summary &&
other.content == content;
@override
int get hashCode => Object.hash(summary, content);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
_ParentLetterPerson(
displayName: json['displayName'] as String? ?? '',
self: json['self'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'self': instance.self,
};
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
_ParentLetterSummary(
id: json['id'] as String,
subject: json['subject'] as String? ?? '',
preview: json['preview'] as String? ?? '',
sender: json['sender'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
sentAt: DateTime.parse(json['sentAt'] as String),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
read: json['read'] as bool? ?? true,
childIds:
(json['childIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterSummaryToJson(
_ParentLetterSummary instance,
) => <String, dynamic>{
'id': instance.id,
'subject': instance.subject,
'preview': instance.preview,
'sender': instance.sender,
'sentAt': instance.sentAt.toIso8601String(),
'editedAt': instance.editedAt?.toIso8601String(),
'read': instance.read,
'childIds': instance.childIds,
'attachmentCount': instance.attachmentCount,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'deadline': instance.deadline?.toIso8601String(),
};
const _$ParentLetterStatusEnumMap = {
ParentLetterStatus.info: 'info',
ParentLetterStatus.open: 'open',
ParentLetterStatus.done: 'done',
ParentLetterStatus.expired: 'expired',
};
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterListResponse(
items:
(json['items'] as List<dynamic>?)
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterListResponseToJson(
_ParentLetterListResponse instance,
) => <String, dynamic>{
'items': instance.items,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
Map<String, dynamic> json,
) => _ParentLetterAttachment(
id: json['id'] as String,
fileName: json['fileName'] as String? ?? '',
mimeType: json['mimeType'] as String? ?? '',
size: (json['size'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterAttachmentToJson(
_ParentLetterAttachment instance,
) => <String, dynamic>{
'id': instance.id,
'fileName': instance.fileName,
'mimeType': instance.mimeType,
'size': instance.size,
};
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
_ParentLetterOption(
id: json['id'] as String,
label: json['label'] as String? ?? '',
);
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
<String, dynamic>{'id': instance.id, 'label': instance.label};
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
_ParentLetterField(
id: json['id'] as String,
type:
$enumDecodeNullable(
_$ParentLetterFieldTypeEnumMap,
json['type'],
unknownValue: ParentLetterFieldType.unknown,
) ??
ParentLetterFieldType.unknown,
label: json['label'] as String? ?? '',
isRequired: json['required'] as bool? ?? false,
options:
(json['options'] as List<dynamic>?)
?.map(
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
<String, dynamic>{
'id': instance.id,
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
'label': instance.label,
'required': instance.isRequired,
'options': instance.options,
};
const _$ParentLetterFieldTypeEnumMap = {
ParentLetterFieldType.singleChoice: 'single_choice',
ParentLetterFieldType.unknown: 'unknown',
};
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
_ParentLetterRequest(
fields:
(json['fields'] as List<dynamic>?)
?.map(
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
signatureRequired: json['signatureRequired'] as bool? ?? false,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterRequestToJson(
_ParentLetterRequest instance,
) => <String, dynamic>{
'fields': instance.fields,
'signatureRequired': instance.signatureRequired,
'deadline': instance.deadline?.toIso8601String(),
};
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
_ParentLetterAnswer(
fieldId: json['fieldId'] as String,
optionIds:
(json['optionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
text: json['text'] as String?,
);
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
<String, dynamic>{
'fieldId': instance.fieldId,
'optionIds': instance.optionIds,
'text': instance.text,
};
_ParentLetterResponse _$ParentLetterResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterResponse(
respondedAt: json['respondedAt'] == null
? null
: DateTime.parse(json['respondedAt'] as String),
respondedBy: json['respondedBy'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(
json['respondedBy'] as Map<String, dynamic>,
),
answers:
(json['answers'] as List<dynamic>?)
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
signed: json['signed'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterResponseToJson(
_ParentLetterResponse instance,
) => <String, dynamic>{
'respondedAt': instance.respondedAt?.toIso8601String(),
'respondedBy': instance.respondedBy,
'answers': instance.answers,
'signed': instance.signed,
};
_ParentLetterChildState _$ParentLetterChildStateFromJson(
Map<String, dynamic> json,
) => _ParentLetterChildState(
childId: json['childId'] as String,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
editable: json['editable'] as bool? ?? false,
response: json['response'] == null
? null
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterChildStateToJson(
_ParentLetterChildState instance,
) => <String, dynamic>{
'childId': instance.childId,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'editable': instance.editable,
'response': instance.response,
};
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
Map<String, dynamic> json,
) => _ParentLetterThreadMessage(
id: json['id'] as String,
author: json['author'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
body: json['body'] as String? ?? '',
sentAt: DateTime.parse(json['sentAt'] as String),
);
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
_ParentLetterThreadMessage instance,
) => <String, dynamic>{
'id': instance.id,
'author': instance.author,
'body': instance.body,
'sentAt': instance.sentAt.toIso8601String(),
};
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
_ParentLetterThread(
enabled: json['enabled'] as bool? ?? false,
messages:
(json['messages'] as List<dynamic>?)
?.map(
(e) => ParentLetterThreadMessage.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'messages': instance.messages,
};
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
_ParentLetterContent(
body: json['body'] as String? ?? '',
attachments:
(json['attachments'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
request: json['request'] == null
? null
: ParentLetterRequest.fromJson(
json['request'] as Map<String, dynamic>,
),
children:
(json['children'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
thread: json['thread'] == null
? const ParentLetterThread()
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterContentToJson(
_ParentLetterContent instance,
) => <String, dynamic>{
'body': instance.body,
'attachments': instance.attachments,
'request': instance.request,
'children': instance.children,
'thread': instance.thread,
};
@@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart';
import 'parent_letter_exception.dart';
/// Base for the parent-letter calls: domain rejections surface as
/// [ParentLetterException].
abstract class ParentLetterQuery extends MarianumConnectQuery {
ParentLetterQuery({super.dio});
String letterPath(String letterId, [String suffix = '']) =>
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
@override
AppException mapError(DioException error) =>
ParentLetterException.fromDio(error);
}
@@ -0,0 +1,20 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
/// idempotent on the server.
class PostParentLetterThreadMessage extends ParentLetterQuery {
PostParentLetterThreadMessage({super.dio});
Future<ParentLetterThreadMessage> run({
required String letterId,
required String body,
required String clientMessageId,
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint(letterPath(letterId, '/thread')),
data: {'body': body, 'clientMessageId': clientMessageId},
);
return ParentLetterThreadMessage.fromJson(response.data!);
});
}
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'dart:typed_data';
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
/// replaces the response for one child. Answers with the updated letter.
class SubmitParentLetterResponse extends ParentLetterQuery {
SubmitParentLetterResponse({super.dio});
Future<ParentLetterDetail> run({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => guard(() async {
final response = await dio.put<Map<String, dynamic>>(
endpoint(
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
),
data: {
'answers': [
for (final answer in answers)
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
],
'signaturePng': signaturePng == null
? null
: base64Encode(signaturePng),
},
);
return ParentLetterDetail.fromJson(response.data!);
});
}
@@ -1,16 +1,18 @@
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via /// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud /// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
/// device-identifier signature, stores the routing metadata and starts /// verifies the device-identifier signature and forwards Nextcloud pushes to
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204. /// this device's FCM token; `direct` registrations (accounts without
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
/// Responds 204.
class PushDeviceRegister extends MarianumConnectQuery { class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio}); PushDeviceRegister({super.dio});
Future<void> run({ Future<void> run({
required String deviceIdentifier, required String deviceIdentifier,
required String deviceIdentifierSignature, String? deviceIdentifierSignature,
required String userPublicKey, String? userPublicKey,
required String pushToken, required String pushToken,
required String platform, required String platform,
required String registrationType, required String registrationType,
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
endpoint('me/push-device'), endpoint('me/push-device'),
data: { data: {
'deviceIdentifier': deviceIdentifier, 'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature, 'deviceIdentifierSignature': ?deviceIdentifierSignature,
'userPublicKey': userPublicKey, 'userPublicKey': ?userPublicKey,
'pushToken': pushToken, 'pushToken': pushToken,
'platform': platform, 'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison // 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')). // value from it (general = sha512(token), talk = sha512(token+'#talk')).
// 'direct' — no Nextcloud subscription behind it.
'registrationType': registrationType, 'registrationType': registrationType,
'appVersion': ?appVersion, 'appVersion': ?appVersion,
}, },
@@ -1,7 +1,7 @@
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../utils/random_id.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on /// A stable, anonymous per-install identifier for telemetry. Generated once on
/// first use (128 bits from a cryptographic RNG) and persisted in the secure /// first use (128 bits from a cryptographic RNG) and persisted in the secure
/// keystore, so a device stays a single row across password rotations and FCM /// keystore, so a device stays a single row across password rotations and FCM
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
_cached = existing; _cached = existing;
return existing; return existing;
} }
final generated = _generate(); final generated = randomHexId();
await _storage.write(key: _key, value: generated); await _storage.write(key: _key, value: generated);
_cached = generated; _cached = generated;
return generated; return generated;
} }
static String _generate() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
} }
@@ -2,8 +2,8 @@ import 'dart:developer';
import 'package:localstore/localstore.dart'; import 'package:localstore/localstore.dart';
import '../../../../model/account_data.dart'; import '../../../../session/session.dart';
import '../../../demo/demo_mode.dart'; import '../../../../session/session_manager.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
@@ -28,12 +28,15 @@ class CustomEventsMigration {
const CustomEventsMigration._(); const CustomEventsMigration._();
static Future<void> runOnce() async { static Future<void> runOnce() async {
if (DemoMode.active) return; // Guardians never had MHSL events; only password accounts can derive the
// legacy identity.
final session = SessionManager().current;
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return; if (await _isDone()) return;
try { try {
final response = await GetCustomTimetableEvent( final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()), GetCustomTimetableEventParams(session.legacyUserSecret),
).run(); ).run();
for (final event in response.events) { for (final event in response.events) {
@@ -44,7 +47,9 @@ class CustomEventsMigration {
} }
await _markDone(); await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
);
} catch (e) { } catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post // Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free. // above keeps a partial run duplicate-free.
@@ -0,0 +1,18 @@
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
/// Fetches the weekly timetable of a guardian's child from
/// `timetable/child/{childId}`. Same response shape as `timetable/me`.
class TimetableGetChildWeek extends MarianumConnectQuery {
TimetableGetChildWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required String childId,
required DateTime from,
required DateTime until,
}) => getObject(
'timetable/child/${Uri.encodeComponent(childId)}',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
+70 -50
View File
@@ -11,11 +11,14 @@ import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart
import 'main.dart'; import 'main.dart';
import 'model/data_cleaner.dart'; import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart'; import 'notification/notification_controller.dart';
import 'notification/notification_service.dart';
import 'notification/notification_tasks.dart'; import 'notification/notification_tasks.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_tap_router.dart'; import 'push/push_tap_router.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/infrastructure/loadable_state/loadable_state.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
@@ -23,13 +26,16 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'state/app/modules/timetable/bloc/timetable_state.dart'; import 'state/app/modules/timetable/bloc/timetable_state.dart';
import 'state/app/modules/timetable/policy/timetable_policy.dart';
import 'storage/settings.dart' as model; import 'storage/settings.dart' as model;
import 'utils/debouncer.dart'; import 'utils/debouncer.dart';
import 'utils/haptics.dart'; import 'utils/haptics.dart';
import 'view/pages/overhang.dart'; import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_navigation.dart'; import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart'; import 'widget_data/widget_publisher.dart';
import 'widget_data/widget_sync.dart';
class App extends StatefulWidget { class App extends StatefulWidget {
const App({super.key}); const App({super.key});
@@ -40,7 +46,6 @@ class App extends StatefulWidget {
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings; late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub; StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub; StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub; StreamSubscription<String>? _fcmTokenRefreshSub;
@@ -115,18 +120,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
void _onPushTapPending() { void _onPushTargetPending() {
final token = PushTapRouter.pendingChatToken.value; final target = PushTapRouter.pendingTarget.value;
if (token == null || !mounted) return; if (target == null || !mounted) return;
PushTapRouter.pendingChatToken.value = null; PushTapRouter.pendingTarget.value = null;
NotificationTasks.navigateToTalk(context, chatToken: token); NotificationTasks.openPushTarget(context, target);
}
void _onNewsletterTapPending() {
final id = PushTapRouter.pendingNewsletterId.value;
if (id == null || !mounted) return;
PushTapRouter.pendingNewsletterId.value = null;
AppRoutes.openNewsletterById(context, id: id);
} }
Future<void> _handlePendingWidgetNavigation() async { Future<void> _handlePendingWidgetNavigation() async {
@@ -142,12 +140,43 @@ class _AppState extends State<App> with WidgetsBindingObserver {
AppRoutes.goToTab(context, Modules.timetable); AppRoutes.goToTab(context, Modules.timetable);
} }
/// Mirrors the primary plan into the home-screen widget without waiting
/// for the periodic background refresh.
void _publishWidget(TimetableBloc bloc) {
final data = bloc.state.data;
if (!mounted || data is! TimetableState) return;
if (WidgetSync.encodeSubject(bloc.subject) == null) return;
unawaited(
WidgetPublisher.publishFromBlocState(
data,
subject: bloc.subject,
settings: context.read<SettingsCubit>().val(),
showClassInsteadOfTeacher: TimetablePolicy.resolve(
subject: bloc.subject,
capabilities: context.read<CapabilitiesCubit>().state,
).showClassInsteadOfTeacher,
),
);
}
void _handlePendingShare() { void _handlePendingShare() {
if (!mounted) return; if (!mounted) return;
final share = ShareIntentListener.pending.value; final share = ShareIntentListener.pending.value;
if (share == null) return; if (share == null) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
// Sharing targets Talk chats and Files folders only.
final session = SessionManager().current;
if (!AppModule.isAvailableFor(Modules.talk, session) &&
!AppModule.isAvailableFor(Modules.files, session)) {
ShareIntentListener.instance.clear();
InfoDialog.show(
context,
'Mit diesem Konto können keine Inhalte in die App geteilt werden.',
title: 'Teilen nicht möglich',
);
return;
}
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
@@ -168,37 +197,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset. // Initial publish in case hydrated storage already has data. No refresh
final timetable = context.read<TimetableBloc>(); // needed: PrimaryTimetableScope hands out a freshly loading bloc.
timetable.refresh(); _publishWidget(context.read<TimetableBloc>());
// Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
_timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
),
);
}
});
// Initial publish in case hydrated storage already has data.
final initialData = timetable.state.data;
if (initialData is TimetableState) {
unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
),
);
}
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.instance.attach(); ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
@@ -227,8 +228,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// Android renders pushes locally, so a tap arrives via the local // Android renders pushes locally, so a tap arrives via the local
// notifications callback (PushTapRouter) rather than onMessageOpenedApp. // notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending); PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending); unawaited(
PushTapRouter.handleAppLaunch(
NotificationService().flutterLocalNotificationsPlugin,
),
);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
@@ -254,12 +259,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_updateTimings.cancel(); _updateTimings.cancel();
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach(); ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
@@ -268,7 +271,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
@override @override
Widget build( Widget build(BuildContext context) =>
BlocListener<TimetableBloc, LoadableState<TimetableState>>(
// Also follows a swapped instance (child switch), unlike a manual
// stream subscription.
listenWhen: (_, state) => !state.isLoading,
// A week change emits several times (week, prefetched neighbours);
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(milliseconds: 500),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
),
child: _buildShell(context),
);
Widget _buildShell(
BuildContext context, BuildContext context,
) => BlocBuilder<SettingsCubit, model.Settings>( ) => BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) { builder: (context, _) {
+22
View File
@@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// Binds a guardian login request to the device that started it (PKCE-style):
/// the request carries only [challengeFor] of a secret that never leaves the
/// device, verification sends the secret itself. A mail link opened on another
/// device therefore cannot complete the login.
abstract final class DeviceBinding {
static String generateSecret([Random? random]) {
final rng = random ?? Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return _base64UrlNoPad(bytes);
}
static String challengeFor(String secret) =>
_base64UrlNoPad(sha256.convert(utf8.encode(secret)).bytes);
static String _base64UrlNoPad(List<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'guardian_login_link.dart';
/// Bridges incoming App Links / Universal Links into [pending]; the login
/// screen consumes it. Mirrors ShareIntentListener: [initialize] reads the
/// cold-start link before `runApp` and then follows links while running.
///
/// Links are kept raw and parsed on consumption: the endpoint setting the
/// link is checked against is only applied once the app is built.
class GuardianLinkListener {
GuardianLinkListener._();
static final GuardianLinkListener instance = GuardianLinkListener._();
static final ValueNotifier<Uri?> pending = ValueNotifier(null);
final AppLinks _appLinks = AppLinks();
bool _listening = false;
Future<void> initialize() async {
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) _publish(initial);
} catch (e) {
debugPrint('GuardianLinkListener.initialize failed: $e');
}
if (_listening) return;
_listening = true;
// Kept for the whole process lifetime; links can arrive at any time.
_appLinks.uriLinkStream.listen(
_publish,
onError: (Object e) => debugPrint('GuardianLinkListener error: $e'),
);
}
// Cheap pre-filter; the host check against the active endpoint happens in
// GuardianLoginLink.parse.
void _publish(Uri uri) {
if (!uri.path.endsWith(GuardianLoginLink.path)) return;
pending.value = uri;
}
static void clear() => pending.value = null;
}
+26
View File
@@ -0,0 +1,26 @@
/// A sign-in link from the guardian login mail
/// (`https://<connect-host>/app/guardian-login?rid=…&lt=…`).
class GuardianLoginLink {
static const path = '/app/guardian-login';
final String requestId;
final String linkToken;
const GuardianLoginLink({required this.requestId, required this.linkToken});
/// Parses [uri] if it is a guardian login link for the server at [apiBase].
/// Links of another server (e.g. live link while the app points at beta)
/// are rejected: the request only exists on the server that sent the mail.
static GuardianLoginLink? parse(Uri uri, {required Uri apiBase}) {
if (uri.scheme != 'https' || uri.host != apiBase.host) return null;
final basePath = apiBase.path.endsWith('/')
? apiBase.path.substring(0, apiBase.path.length - 1)
: apiBase.path;
if (uri.path != '$basePath$path') return null;
final requestId = uri.queryParameters['rid'];
final linkToken = uri.queryParameters['lt'];
if (requestId == null || requestId.isEmpty) return null;
if (linkToken == null || linkToken.isEmpty) return null;
return GuardianLoginLink(requestId: requestId, linkToken: linkToken);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A guardian login request waiting for its code or link. Persisted because
/// Android often kills the app while the user reads the mail.
class PendingGuardianRequest {
static const int defaultCodeLength = 6;
final String requestId;
final String email;
final String deviceSecret;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const PendingGuardianRequest({
required this.requestId,
required this.email,
required this.deviceSecret,
required this.expiresAt,
required this.resendAvailableAt,
this.codeLength = defaultCodeLength,
});
bool isExpired(DateTime now) => !now.isBefore(expiresAt);
Map<String, Object> toJson() => {
'requestId': requestId,
'email': email,
'deviceSecret': deviceSecret,
'expiresAt': expiresAt.toIso8601String(),
'resendAvailableAt': resendAvailableAt.toIso8601String(),
'codeLength': codeLength,
};
static PendingGuardianRequest? fromJson(Map<String, dynamic> json) {
try {
return PendingGuardianRequest(
requestId: json['requestId'] as String,
email: json['email'] as String,
deviceSecret: json['deviceSecret'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength: json['codeLength'] as int? ?? defaultCodeLength,
);
} on Object {
return null;
}
}
}
class PendingGuardianRequestStore {
static const _key = 'guardian_login_pending_request';
static const FlutterSecureStorage _storage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
const PendingGuardianRequestStore();
Future<PendingGuardianRequest?> read() async {
try {
final raw = await _storage.read(key: _key);
if (raw == null) return null;
return PendingGuardianRequest.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on Object {
return null;
}
}
Future<void> write(PendingGuardianRequest request) =>
_storage.write(key: _key, value: jsonEncode(request.toJson()));
Future<void> clear() async {
try {
await _storage.delete(key: _key);
} on Object {
// Nothing stored or keystore unavailable.
}
}
}
+53 -22
View File
@@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
@@ -14,11 +15,11 @@ import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subj
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../model/account_data.dart'; import '../session/session.dart';
import '../session/session_manager.dart';
import '../state/app/modules/timetable/data_provider/timetable_data_provider.dart';
import '../state/app/modules/timetable/subject/timetable_subject.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
@@ -81,17 +82,17 @@ class WidgetBackgroundTask {
/// Throws on fetch failure so the worker path can signal a retry. /// Throws on fetch failure so the worker path can signal a retry.
static Future<void> runRefreshNow({bool force = false}) async { static Future<void> runRefreshNow({bool force = false}) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
bool populated; Session? session;
try { try {
// Bounded: a hanging keystore read must not stall the caller's budget // Bounded: a hanging keystore read must not stall the caller's budget
// (FCM handler ~25s on iOS) forever. // (FCM handler ~25s on iOS) forever.
populated = await AccountData().waitForPopulation().timeout( session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
); );
} on TimeoutException { } on TimeoutException {
populated = false; session = null;
} }
if (!populated) { if (session == null) {
// Deliberately does NOT flip the widget to logged-out: a failed or slow // Deliberately does NOT flip the widget to logged-out: a failed or slow
// keychain read (locked iOS device during the 06:00 silent push) is // keychain read (locked iOS device during the 06:00 silent push) is
// indistinguishable from "never logged in" here, and blanking the // indistinguishable from "never logged in" here, and blanking the
@@ -101,11 +102,23 @@ class WidgetBackgroundTask {
return; return;
} }
final fetchedAt = await WidgetSync.getFetchedAt(); final fetchedAt = await WidgetSync.getFetchedAt();
if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { if (shouldSkipRefresh(
fetchedAt: fetchedAt,
now: DateTime.now(),
force: force,
)) {
log('[widget-refresh] snapshot is fresh, skipping refresh'); log('[widget-refresh] snapshot is fresh, skipping refresh');
return; return;
} }
await _refresh(); final subject = widgetRefreshSubject(
session: session,
stored: await WidgetSync.getSubject(),
);
if (subject == null) {
log('[widget-refresh] no plan selected yet, skipping refresh');
return;
}
await _refresh(subject);
} }
static Future<void> cancelAll() async { static Future<void> cancelAll() async {
@@ -126,6 +139,16 @@ bool shouldSkipRefresh({
return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
} }
/// The plan the background refresh loads. Guardians only get one once the app
/// has published a child; before that there is nothing sensible to fetch.
TimetableSubject? widgetRefreshSubject({
required Session session,
required TimetableSubject? stored,
}) => switch (session) {
CredentialSession() => const OwnTimetable(),
GuardianSession() => stored is ChildTimetable ? stored : null,
};
@pragma('vm:entry-point') @pragma('vm:entry-point')
void _callbackDispatcher() { void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
@@ -142,7 +165,7 @@ void _callbackDispatcher() {
}); });
} }
Future<void> _refresh() async { Future<void> _refresh(TimetableSubject subject) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we // The background isolate doesn't go through main.dart's BlocBuilder, so we
// re-apply the endpoint the foreground last persisted. Without this the // re-apply the endpoint the foreground last persisted. Without this the
@@ -165,9 +188,11 @@ Future<void> _refresh() async {
// latency is the slowest request, not the sum (matters for the push path's // latency is the slowest request, not the sum (matters for the push path's
// hard time budget). Reference-data failures fall through to null in the // hard time budget). Reference-data failures fall through to null in the
// mapper rather than aborting the whole refresh. // mapper rather than aborting the whole refresh.
final timetableFuture = TimetableGetWeek().run( final until = weekEndExclusive.subtract(const Duration(days: 1));
final timetableFuture = TimetableDataProvider.fetchWeek(
subject,
from: weekStart, from: weekStart,
until: weekEndExclusive.subtract(const Duration(days: 1)), until: until,
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>( final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
@@ -181,11 +206,11 @@ Future<void> _refresh() async {
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>( final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(), () => TimetableGetTimegrid().run(),
); );
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>( final customEventsFuture = subject.supportsCustomEvents
() => GetCustomTimetableEvent( ? _runOrNull<GetCustomTimetableEventResponse>(
GetCustomTimetableEventParams(AccountData().getUserSecret()), () => TimetableCustomEventsGet().run(),
).run(), )
); : Future<GetCustomTimetableEventResponse?>.value();
final timetable = await timetableFuture; final timetable = await timetableFuture;
final subjects = await subjectsFuture; final subjects = await subjectsFuture;
final rooms = await roomsFuture; final rooms = await roomsFuture;
@@ -195,9 +220,9 @@ Future<void> _refresh() async {
final lessons = timetable.entries; final lessons = timetable.entries;
final [connectDouble, isTeacher] = await Future.wait([ final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(), WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(), WidgetSync.getShowClassInsteadOfTeacher(),
]); ]);
final dayData = WidgetDataMapper.buildDayData( final dayData = WidgetDataMapper.buildDayData(
now: now, now: now,
@@ -208,7 +233,7 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
final weekData = WidgetDataMapper.buildWeekData( final weekData = WidgetDataMapper.buildWeekData(
now: now, now: now,
@@ -219,9 +244,15 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
// The user may have switched the child while the requests ran; writing now
// would show the previous child's plan.
if (await WidgetSync.getSubject() != subject) {
log('[widget-bg] subject changed during refresh, discarding');
return;
}
await WidgetSync.writeDayData(dayData); await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData); await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true); await WidgetSync.setLoggedIn(true);
+113 -62
View File
@@ -24,15 +24,17 @@ import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart'; import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/account_data.dart';
import 'notification/notification_service.dart'; import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart'; import 'push/push_message_handler.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_registration_store.dart'; import 'push/push_registration_store.dart';
import 'push/push_renderer.dart'; import 'push/push_renderer.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/account/bloc/account_state.dart';
@@ -40,14 +42,17 @@ import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/children/child_selection_cubit.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/primary/primary_timetable_scope.dart';
import 'storage/hydrated_storage_bootstrap.dart'; import 'storage/hydrated_storage_bootstrap.dart';
import 'storage/settings.dart'; import 'storage/settings.dart';
import 'theming/dark_app_theme.dart'; import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart'; import 'theming/light_app_theme.dart';
import 'utils/app_paths.dart'; import 'utils/app_paths.dart';
import 'utils/debouncer.dart';
import 'utils/downloads/download_manager.dart'; import 'utils/downloads/download_manager.dart';
import 'view/login/account_loading_screen.dart'; import 'view/login/account_loading_screen.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
@@ -149,17 +154,18 @@ Future<void> main() async {
_startupStep('documents dir', () async { _startupStep('documents dir', () async {
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
}), }),
// The keychain may still be locked right after device unlock; AccountData // The keychain may still be locked right after device unlock; the session
// keeps retrying, so on timeout the app starts on the loading screen and // keeps retrying, so on timeout the app starts on the loading screen and
// flips to the real state once the session is readable (see _MainState). // flips to the real state once the session is readable (see _MainState).
_startupStep( _startupStep(
'account data', 'account data',
AccountData().waitForPopulation, SessionManager().waitForLoad,
timeout: const Duration(seconds: 5), timeout: const Duration(seconds: 5),
// Expected on every background wake of a locked device; not an error. // Expected on every background wake of a locked device; not an error.
report: false, report: false,
), ),
_startupStep('share intent', ShareIntentListener.instance.initialize), _startupStep('share intent', ShareIntentListener.instance.initialize),
_startupStep('guardian link', GuardianLinkListener.instance.initialize),
]; ];
log('starting app initialisation...'); log('starting app initialisation...');
@@ -215,7 +221,7 @@ Future<void> main() async {
// has data ready by the time the user navigates to it. No-op when a // has data ready by the time the user navigates to it. No-op when a
// cached payload is already present, so this does not undo the day-long // cached payload is already present, so this does not undo the day-long
// root cache TTL. // root cache TTL.
if (AccountData().isPopulated()) { if (SessionManager().hasNextcloud) {
unawaited( unawaited(
ListFilesCache.prefetchRootListing().onError( ListFilesCache.prefetchRootListing().onError(
(e, _) => log('Files root prefetch failed: $e'), (e, _) => log('Files root prefetch failed: $e'),
@@ -228,11 +234,17 @@ Future<void> main() async {
// placeholder flash. // placeholder flash.
AvatarDiskCache.instance.warmUp(); AvatarDiskCache.instance.warmUp();
// Created eagerly so the endpoint is configured before anything below can
// issue a request (the primary timetable bloc loads on creation).
final settingsCubit = SettingsCubit();
_syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
log('running app...'); log('running app...');
runApp( runApp(
MultiBlocProvider( MultiBlocProvider(
providers: [ providers: [
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()), BlocProvider<SettingsCubit>.value(value: settingsCubit),
BlocProvider<AccountBloc>( BlocProvider<AccountBloc>(
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
), ),
@@ -245,17 +257,32 @@ Future<void> main() async {
BlocProvider<ChatBloc>( BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()), create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
), ),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()), BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
], ],
child: const Main(), child: const PrimaryTimetableScope(child: Main()),
), ),
); );
} }
String? _syncedMcBaseUrl;
/// Keeps the MC dio singleton aligned with the selected endpoint (live /
/// beta / custom), mirrored into WidgetSync so the background isolate
/// refreshes against the same endpoint. Settings emit on every toggle; only
/// an actual URL change is applied.
void _syncMarianumConnectEndpoint(Settings settings) {
final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl();
if (url == _syncedMcBaseUrl) return;
_syncedMcBaseUrl = url;
MarianumConnectEndpoint.update(url);
unawaited(WidgetSync.setMarianumConnectBaseUrl(url));
}
AccountStatus _initialAccountStatus() { AccountStatus _initialAccountStatus() {
final account = AccountData(); final session = SessionManager();
if (account.isPopulated()) return AccountStatus.loggedIn; if (session.isSignedIn) return AccountStatus.loggedIn;
return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
} }
class Main extends StatefulWidget { class Main extends StatefulWidget {
@@ -278,35 +305,55 @@ class _MainState extends State<Main> {
super.initState(); super.initState();
Jiffy.setLocale('de'); Jiffy.setLocale('de');
AccountData().waitForPopulation().then((value) { SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
SessionManager().waitForLoad().then((session) {
if (!mounted) return; if (!mounted) return;
final accountBloc = context.read<AccountBloc>(); final accountBloc = context.read<AccountBloc>();
accountBloc.setStatus( accountBloc.setStatus(
value ? AccountStatus.loggedIn : AccountStatus.loggedOut, session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut,
); );
if (value) { if (session != null) {
_scheduleSessionValidation(accountBloc); _scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't // Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires — refresh // change, so the loggedIn listener below never fires.
// capabilities here, then self-heal the push registration. _onSessionActive();
final settingsCubit = context.read<SettingsCubit>();
unawaited(
context.read<CapabilitiesCubit>().load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, context.read<CapabilitiesCubit>());
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
} }
}); });
} }
/// Warms the core caches (timetable, chat list, files root) in the /// Pulls the capability flags of the active account, then registers push
/// background so the first screen render hits populated data. /// right away instead of deferring it to the next app start.
void _onSessionActive() {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
}
/// Waits for the post-login splash so the dialog never covers it; the
/// splash's completion calls this again.
void _promptGuardianNotifications() {
if (_showPostLoginSplash) return;
final overlayContext = AppRoutes.overlayContext;
if (overlayContext == null) return;
unawaited(maybePromptGuardianLoginNotifications(overlayContext));
}
/// Warms the chat list and files root in the background so the first screen
/// render hits populated data. The timetable needs no warm-up:
/// PrimaryTimetableScope creates a freshly loading bloc per account.
void _prefetchBaseData(BuildContext context) { void _prefetchBaseData(BuildContext context) {
context.read<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().refresh(silent: true)); unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(ListFilesCache.prefetchRootListing()); unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing());
}
} }
/// Registers/self-heals the push subscription whenever the backend advertises /// Registers/self-heals the push subscription whenever the backend advertises
@@ -329,6 +376,23 @@ class _MainState extends State<Main> {
); );
} }
@override
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
super.dispose();
}
void _onUnauthorized() {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
Debouncer.throttle(
'sessionUnauthorized',
const Duration(seconds: 30),
() => _scheduleSessionValidation(accountBloc),
);
}
/// Background credential check: a 401 means the password was rotated /// Background credential check: a 401 means the password was rotated
/// server-side, so the validator wipes the local session and flips the /// server-side, so the validator wipes the local session and flips the
/// account bloc to `loggedOut` (sending the user to the login screen). /// account bloc to `loggedOut` (sending the user to the login screen).
@@ -349,14 +413,6 @@ class _MainState extends State<Main> {
child: BlocBuilder<SettingsCubit, Settings>( child: BlocBuilder<SettingsCubit, Settings>(
builder: (context, settings) { builder: (context, settings) {
final devToolsSettings = settings.devToolsSettings; final devToolsSettings = settings.devToolsSettings;
// Keep the MC dio singleton aligned with the currently selected
// endpoint (live / beta / custom). Idempotent when the URL is
// unchanged so it's safe to call on every rebuild. Mirrored into
// WidgetSync so the background isolate refreshes against the same
// endpoint.
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
MarianumConnectEndpoint.update(mcBaseUrl);
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
// Mirror the notification toggle into group-scoped storage so the FCM // Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off. // background isolate and the iOS NSE can suppress rendering when off.
unawaited( unawaited(
@@ -409,22 +465,8 @@ class _MainState extends State<Main> {
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous.status != current.status, previous.status != current.status,
listener: (context, accountState) { listener: (context, accountState) {
// Fresh login (loggedOut -> loggedIn): pull capability flags
// for the newly authenticated user, then register push right
// away instead of deferring it to the next app start.
if (accountState.status == AccountStatus.loggedIn) { if (accountState.status == AccountStatus.loggedIn) {
final settingsCubit = context.read<SettingsCubit>(); _onSessionActive();
final capabilitiesCubit = context
.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
}),
);
unawaited(
context.read<NextcloudCapabilitiesCubit>().load(),
);
_showPostLoginSplash = true; _showPostLoginSplash = true;
_appMounted = false; _appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -449,11 +491,13 @@ class _MainState extends State<Main> {
// — by the time it runs the dialog/Settings context is // — by the time it runs the dialog/Settings context is
// gone but this listener context is still valid. // gone but this listener context is still valid.
final settingsCubit = context.read<SettingsCubit>(); final settingsCubit = context.read<SettingsCubit>();
final timetableBloc = context.read<TimetableBloc>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final breakerBloc = context.read<BreakerBloc>(); final breakerBloc = context.read<BreakerBloc>();
final capabilitiesCubit = context.read<CapabilitiesCubit>(); final capabilitiesCubit = context.read<CapabilitiesCubit>();
final childSelectionCubit = context
.read<ChildSelectionCubit>();
final chatListBloc = context.read<ChatListBloc>();
final parentLettersBloc = context.read<ParentLettersBloc>();
final chatBloc = context.read<ChatBloc>();
final nextcloudCapabilitiesCubit = context final nextcloudCapabilitiesCubit = context
.read<NextcloudCapabilitiesCubit>(); .read<NextcloudCapabilitiesCubit>();
// Defer the actual wipe until after this frame so the // Defer the actual wipe until after this frame so the
@@ -464,8 +508,9 @@ class _MainState extends State<Main> {
unawaited( unawaited(
_wipeUserState( _wipeUserState(
settingsCubit: settingsCubit, settingsCubit: settingsCubit,
timetableBloc: timetableBloc, childSelectionCubit: childSelectionCubit,
chatListBloc: chatListBloc, chatListBloc: chatListBloc,
parentLettersBloc: parentLettersBloc,
chatBloc: chatBloc, chatBloc: chatBloc,
breakerBloc: breakerBloc, breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit, capabilitiesCubit: capabilitiesCubit,
@@ -486,9 +531,10 @@ class _MainState extends State<Main> {
if (_showPostLoginSplash) if (_showPostLoginSplash)
PostLoginSplash( PostLoginSplash(
key: const ValueKey('post-login-splash'), key: const ValueKey('post-login-splash'),
onComplete: () => setState( onComplete: () {
() => _showPostLoginSplash = false, setState(() => _showPostLoginSplash = false);
), _promptGuardianNotifications();
},
), ),
], ],
); );
@@ -510,8 +556,9 @@ class _MainState extends State<Main> {
Future<void> _wipeUserState({ Future<void> _wipeUserState({
required SettingsCubit settingsCubit, required SettingsCubit settingsCubit,
required TimetableBloc timetableBloc, required ChildSelectionCubit childSelectionCubit,
required ChatListBloc chatListBloc, required ChatListBloc chatListBloc,
required ParentLettersBloc parentLettersBloc,
required ChatBloc chatBloc, required ChatBloc chatBloc,
required BreakerBloc breakerBloc, required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit, required CapabilitiesCubit capabilitiesCubit,
@@ -523,13 +570,17 @@ Future<void> _wipeUserState({
// wraps MaterialApp, so emit'ing a fresh state would tear down the // wraps MaterialApp, so emit'ing a fresh state would tear down the
// freshly-mounted Login tree and leave the user with a blank screen // freshly-mounted Login tree and leave the user with a blank screen
// (the MaterialApp.builder backdrop) until the next interaction. // (the MaterialApp.builder backdrop) until the next interaction.
// The timetable bloc is not reset here: PrimaryTimetableScope replaces it
// on the status change, and HydratedBloc.storage.clear() below drops the
// cached plans of every subject.
childSelectionCubit.reset();
capabilitiesCubit.reset();
nextcloudCapabilitiesCubit.reset();
await Future.wait([ await Future.wait([
timetableBloc.reset(),
chatListBloc.reset(), chatListBloc.reset(),
parentLettersBloc.reset(),
chatBloc.reset(), chatBloc.reset(),
breakerBloc.reset(), breakerBloc.reset(),
capabilitiesCubit.reset(),
nextcloudCapabilitiesCubit.reset(),
]); ]);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.clear(); await prefs.clear();
-333
View File
@@ -1,333 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
class AccountData {
static const _usernameField = 'username';
static const _passwordField = 'password';
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too.
// The talk password authenticates the second (apptype=talk) push
// registration — Nextcloud binds each push subscription to its session
// token, so two registrations need two app passwords.
static const _appPasswordField = 'nextcloud_app_password';
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
// Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA):
// the real password is not valid against Nextcloud, only the flow-issued
// app password is — and no further app passwords can be minted silently.
static const _loginFlowField = 'nextcloud_login_flow';
// Persists the demo session across cold starts (see DemoMode).
static const _demoField = 'is_demo';
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
static const _demoPasswordPlaceholder = 'demo';
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
_usernameField,
_passwordField,
_demoField,
_loginFlowField,
];
static final AccountData _instance = AccountData._construct();
Completer<void> _populated = Completer();
factory AccountData() => _instance;
AccountData._construct() {
unawaited(_loadWithRetry());
}
String? _username;
String? _password;
String? _appPassword;
String? _appPasswordTalk;
bool _isDemo = false;
bool _usesLoginFlow = false;
/// True while the active session is a local demo session (see DemoMode).
bool get isDemo => _isDemo;
/// True when the Nextcloud credentials were obtained via Login Flow v2
/// (browser login, e.g. because the account has two-factor authentication).
/// In that mode the stored real password only authenticates MarianumConnect;
/// every Nextcloud call must use the flow-issued app password.
bool get usesLoginFlow => _usesLoginFlow;
String getUsername() {
if (_username == null) throw Exception('Username not initialized');
return _username!;
}
String getPassword() {
if (_password == null) throw Exception('Password not initialized');
return _password!;
}
String getUserSecret() => sha512
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
.toString();
Future<void> setData(String username, String password) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(key: _passwordField, value: password);
_username = username;
_password = password;
if (!_populated.isCompleted) _populated.complete();
}
/// Enters a local demo session for [username] — no real credentials or token;
/// every backend is served from fixtures while [isDemo] is true (see DemoMode).
Future<void> setDemo(String username) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(
key: _passwordField,
value: _demoPasswordPlaceholder,
);
await _secureStorage.write(key: _demoField, value: 'true');
_username = username;
_password = _demoPasswordPlaceholder;
_isDemo = true;
if (!_populated.isCompleted) _populated.complete();
}
Future<void> removeData() async {
_populated = Completer();
_username = null;
_password = null;
_appPassword = null;
_appPasswordTalk = null;
_isDemo = false;
_usesLoginFlow = false;
await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField);
await _secureStorage.delete(key: _demoField);
await _secureStorage.delete(key: _loginFlowField);
await _clearAppPasswordStorage();
await _clearAppPasswordTalkStorage();
}
/// Persists a freshly minted Nextcloud app password. After this every
/// [getBasicAuthHeader] call authenticates with the app password instead of
/// the real password.
Future<void> setAppPassword(String appPassword) async {
_appPassword = appPassword;
try {
await pushSecureStorage.write(key: _appPasswordField, value: appPassword);
} on Object {
// Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet
// provisioned). Keeping it in memory still lets this session use it.
}
}
Future<void> clearAppPassword() async {
_appPassword = null;
await _clearAppPasswordStorage();
}
/// Adopts an app password obtained via Login Flow v2 and switches the
/// account into flow mode (see [usesLoginFlow]). Any previously stored Talk
/// app password belonged to the old session era and is dropped — the second
/// (optional) flow pass stores a fresh one via [setAppPasswordTalk].
Future<void> setLoginFlow(String appPassword) async {
await setAppPassword(appPassword);
await clearAppPasswordTalk();
_usesLoginFlow = true;
await _secureStorage.write(key: _loginFlowField, value: 'true');
}
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
/// Persists the app password backing the Talk push registration.
Future<void> setAppPasswordTalk(String appPassword) async {
_appPasswordTalk = appPassword;
try {
await pushSecureStorage.write(
key: _appPasswordTalkField,
value: appPassword,
);
} on Object {
// Group-scoped keystore may be unavailable — in-memory still works for
// this session, matching setAppPassword.
}
}
Future<void> clearAppPasswordTalk() async {
_appPasswordTalk = null;
await _clearAppPasswordTalkStorage();
}
bool hasAppPasswordTalk() =>
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
Future<void> _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
Future<void> _clearAppPasswordTalkStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordTalkField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_populated.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
/// Stops waiting for the stored session; the app then behaves as logged
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_populated.isCompleted) _populated.complete();
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
_username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField);
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
_usesLoginFlow =
(await _secureStorage.read(key: _loginFlowField)) == 'true';
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
_appPasswordTalk = await pushSecureStorage.read(
key: _appPasswordTalkField,
);
} on Object {
_appPassword = null;
_appPasswordTalk = null;
}
if (!_populated.isCompleted) _populated.complete();
}
// Move credentials from the old SharedPreferences plain-text storage into the
// platform's secure keystore. Run once per install and clear the legacy keys.
Future<void> _migrateFromLegacyStorage() async {
final prefs = await SharedPreferences.getInstance();
final legacyUsername = prefs.getString(_usernameField);
final legacyPassword = prefs.getString(_passwordField);
if (legacyUsername == null || legacyPassword == null) return;
final hasSecure = (await _secureStorage.read(key: _usernameField)) != null;
if (!hasSecure) {
await _secureStorage.write(key: _usernameField, value: legacyUsername);
await _secureStorage.write(key: _passwordField, value: legacyPassword);
}
await prefs.remove(_usernameField);
await prefs.remove(_passwordField);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
for (final field in _sessionFields) {
final value = await _legacySecureStorage.read(key: field);
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
Future<bool> waitForPopulation() async {
await _populated.future;
return isPopulated();
}
/// True once the stored session has been read (or given up on).
bool get isLoaded => _populated.isCompleted;
bool isPopulated() => _username != null && _password != null;
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
/// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers.
String getBasicAuthHeader() {
_requirePopulated();
// Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to.
return _basicAuth(_appPassword ?? _password!);
}
/// Basic-auth header using the Talk app password — authenticates the
/// apptype=talk push registration (and its unregister). Throws when the
/// talk password has not been minted yet; callers treat that as a failed
/// talk registration and retry on the next start.
String getTalkBasicAuthHeader() {
_requirePopulated();
if (!hasAppPasswordTalk()) {
// Login-flow account whose second (talk) flow pass was skipped: no
// silent minting possible, the talk registration shares the single
// flow-issued credential.
if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!);
throw StateError('Talk app password not available yet');
}
return _basicAuth(_appPasswordTalk!);
}
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
String getRealPasswordBasicAuthHeader() {
_requirePopulated();
return _basicAuth(_password!);
}
/// Secret authenticating against Nextcloud: the app password once available
/// (minted or flow-issued), otherwise the real password. Mirrors the
/// preference of [getBasicAuthHeader] for clients that need the raw secret
/// (WebDAV client construction).
String getNextcloudSecret() {
_requirePopulated();
return _appPassword ?? _password!;
}
void _requirePopulated() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
}
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
/// header map ready to merge into HTTP request headers.
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
}
+6 -22
View File
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart'; import '../push/push_message_handler.dart';
import '../routing/app_routes.dart'; import '../push/push_target.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
@@ -42,18 +42,15 @@ class NotificationController {
RemoteMessage message, RemoteMessage message,
BuildContext context, BuildContext context,
) async { ) async {
final newsletterId = _extractNewsletterId(message); final target = resolvePushTarget(message.data);
if (newsletterId != null) { if (target != null) {
AppRoutes.openNewsletterById( NotificationTasks.openPushTarget(
context, context,
id: newsletterId, target,
title: message.notification?.title, title: message.notification?.title,
); );
} else { } else {
NotificationTasks.navigateToTalk( NotificationTasks.navigateToTalk(context);
context,
chatToken: _extractChatToken(message),
);
} }
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
unawaited(NotificationTasks.refreshBadge()); unawaited(NotificationTasks.refreshBadge());
@@ -68,17 +65,4 @@ class NotificationController {
); );
}); });
} }
static String? _extractChatToken(RemoteMessage message) {
for (final key in const ['chatToken', 'token', 'roomToken']) {
final value = message.data[key];
if (value is String && value.isNotEmpty) return value;
}
return null;
}
static String? _extractNewsletterId(RemoteMessage message) {
final value = message.data['newsletterId'];
return value is String && value.isNotEmpty ? value : null;
}
} }
+43
View File
@@ -7,8 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart'; import '../push/chat_thread_store.dart';
import '../push/push_renderer.dart';
import '../push/push_target.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'notification_service.dart'; import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
@@ -70,12 +75,50 @@ class NotificationTasks {
/// even if the user has already left. /// even if the user has already left.
static void updateProviders(BuildContext context) { static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
context.read<ParentLettersBloc>().refresh(silent: true);
}
/// [title] is the notification title, when the tap came with one.
static void openPushTarget(
BuildContext context,
PushTarget target, {
String? title,
}) {
switch (target) {
case ParentLetterTarget(:final letterId):
if (AppModule.isAvailableFor(
Modules.parentLetters,
SessionManager().current,
)) {
AppRoutes.openParentLetter(context, id: letterId);
}
case NewsletterTarget(:final newsletterId):
AppRoutes.openNewsletterById(context, id: newsletterId, title: title);
case ChatTarget(:final chatToken):
navigateToTalk(context, chatToken: chatToken);
}
}
/// Removes the letter's tray notification (Android; iOS alerts are posted by
/// the system and cannot be addressed by id).
static Future<void> clearParentLetterNotification(String letterId) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: PushRenderer.parentLetterNotificationId(letterId),
);
await refreshBadge();
} on Object catch (e) {
log('Parent letter notification cleanup failed: $e');
}
} }
/// Switches to the Talk tab. If [chatToken] is provided, also schedules /// Switches to the Talk tab. If [chatToken] is provided, also schedules
/// the matching chat to be opened automatically once the chat list view /// the matching chat to be opened automatically once the chat list view
/// resolves the token (handled inside [ChatList]). /// resolves the token (handled inside [ChatList]).
static void navigateToTalk(BuildContext context, {String? chatToken}) { static void navigateToTalk(BuildContext context, {String? chatToken}) {
if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) {
return;
}
if (chatToken != null && chatToken.isNotEmpty) { if (chatToken != null && chatToken.isNotEmpty) {
AppRoutes.openChatByToken(context, chatToken); AppRoutes.openChatByToken(context, chatToken);
} else { } else {
+71
View File
@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../utils/random_id.dart';
import 'push_device_info.dart';
import 'push_secure_storage.dart';
/// Push registration for accounts without Nextcloud (guardians): the device
/// registers straight with MarianumConnect and only receives its direct
/// pushes (newsletter, widget refresh, later guardian messages). Nextcloud
/// normally supplies the device identifier; here a random one is kept per
/// install.
class DirectPushRegistration {
static const String registrationType = 'direct';
static const _deviceIdentifierKey = 'push_direct_device_identifier';
final FlutterSecureStorageLike _storage;
const DirectPushRegistration({
FlutterSecureStorageLike storage = const PushSecureStorage(),
}) : _storage = storage;
Future<bool> register() async {
try {
final (fcmToken, appVersion, identifier) = await (
FirebaseMessaging.instance.getToken(),
pushAppVersion(),
deviceIdentifier(),
).wait;
if (fcmToken == null || fcmToken.isEmpty) {
log('Push (direct): no FCM token, skipping registration');
return false;
}
await PushDeviceRegister().run(
deviceIdentifier: identifier,
pushToken: fcmToken,
platform: pushPlatform,
registrationType: registrationType,
appVersion: appVersion,
);
return true;
} on Object catch (e) {
log('Push (direct): registration failed: $e');
return false;
}
}
Future<void> unregister() async {
final identifier = await _storage.read(key: _deviceIdentifierKey);
if (identifier == null) return;
try {
await PushDeviceUnregister().run(deviceIdentifier: identifier);
} on Object catch (e) {
log('Push (direct): unregister failed: $e');
}
await _storage.delete(key: _deviceIdentifierKey);
}
/// Stable per install until [unregister], so re-registrations upsert the
/// same server row instead of piling up devices.
Future<String> deviceIdentifier() async {
final stored = await _storage.read(key: _deviceIdentifierKey);
if (stored != null && stored.isNotEmpty) return stored;
final fresh = randomHexId();
await _storage.write(key: _deviceIdentifierKey, value: fresh);
return fresh;
}
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:developer';
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../storage/notification_settings.dart';
import '../widget/confirm_dialog.dart';
import 'push_registration.dart';
const _talkExplanation =
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
'sie, um Push-Nachrichten zu erhalten.';
const _talkDeclinedNote =
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
'deines Geräts nachträglich aktivieren.';
const _parentLetterExplanation =
'Damit du keine Elternbriefe der Schule verpasst, fragen wir dich gleich '
'nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere sie, um '
'Push-Nachrichten zu erhalten.';
const _parentLetterDeclinedNote =
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
'Elternbriefen. Du kannst sie jederzeit in den Systemeinstellungen deines '
'Geräts nachträglich aktivieren.';
/// The occasions with a guided notification-permission flow. Each runs at
/// most once per install, guarded by its own flag in [NotificationSettings].
enum _PermissionPrompt {
talkVisit(_talkExplanation, _talkDeclinedNote),
guardianLogin(
_parentLetterExplanation,
_parentLetterDeclinedNote,
module: Modules.parentLetters,
spentOnDisplay: true,
),
parentLettersVisit(
_parentLetterExplanation,
_parentLetterDeclinedNote,
module: Modules.parentLetters,
);
const _PermissionPrompt(
this.explanation,
this.declinedNote, {
this.module,
this.spentOnDisplay = false,
});
final String explanation;
final String declinedNote;
/// Only sessions that have this module are asked.
final Modules? module;
/// Spends the one-shot as soon as the explanation is displayed, so a
/// dismissed dialog does not come back on every app start.
final bool spentOnDisplay;
bool wasShown(NotificationSettings settings) => switch (this) {
talkVisit => settings.talkPermissionPromptShown,
guardianLogin => settings.guardianLoginPromptShown,
parentLettersVisit => settings.parentLettersPromptShown,
};
void markShown(NotificationSettings settings) {
switch (this) {
case talkVisit:
settings.talkPermissionPromptShown = true;
case guardianLogin:
settings.guardianLoginPromptShown = true;
case parentLettersVisit:
settings.parentLettersPromptShown = true;
}
}
}
/// Shows the one-time notification-permission flow on the first Talk visit.
///
/// The OS prompt is deliberately kept out of the cold-start path (younger users
/// decline it reflexively before ever seeing why they'd want it). Instead, the
/// first time Talk is opened we explain the request, then trigger the OS prompt,
/// and — if declined — offer a shortcut to the system settings.
///
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
Future<void> maybePromptTalkNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.talkVisit);
/// Accounts that receive parent letters never reach the Talk flow, so they
/// are asked once right after signing in.
Future<void> maybePromptGuardianLoginNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.guardianLogin);
/// Second chance with context: explains the request once more on the first
/// visit of the parent letters when the permission is still missing.
Future<void> maybePromptParentLetterNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.parentLettersVisit);
bool _promptInFlight = false;
Future<void> _maybePrompt(
BuildContext context,
_PermissionPrompt prompt,
) async {
final module = prompt.module;
if (module != null &&
!AppModule.isAvailableFor(module, SessionManager().current)) {
return;
}
final settings = context.read<SettingsCubit>();
final notificationSettings = settings.val().notificationSettings;
// Already handled once, or the user opted out of push entirely.
if (prompt.wasShown(notificationSettings)) return;
if (!notificationSettings.enabled) return;
// Capabilities may still be loading on a fresh cold start; retry on the next
// occasion instead of burning the one-shot flag.
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
if (_promptInFlight) return;
_promptInFlight = true;
try {
// Users who already granted the permission: register silently and mark the
// prompt as handled without showing any dialog.
if (await PushRegistration.isOsPermissionGranted()) {
prompt.markShown(settings.val(write: true).notificationSettings);
unawaited(PushRegistration().register());
return;
}
if (!context.mounted) return;
if (prompt.spentOnDisplay) {
prompt.markShown(settings.val(write: true).notificationSettings);
}
// The OS prompt (and its "declined" follow-up) outlive this dialog, so
// hold on to them: releasing the guard at the dialog's close would let a
// second occasion stack another dialog over the pending OS prompt.
Future<void>? permissionRequest;
await showDialog<void>(
context: context,
builder: ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content: prompt.explanation,
confirmButton: 'Weiter',
cancelButton: null,
onConfirm: () =>
permissionRequest = _requestPermission(context, settings, prompt),
).build,
);
await permissionRequest;
} finally {
_promptInFlight = false;
}
}
Future<void> _requestPermission(
BuildContext context,
SettingsCubit settings,
_PermissionPrompt prompt,
) async {
final granted = await PushRegistration.requestOsPermission();
// Mark handled regardless of the outcome — the user can re-enable later via
// the system settings; we don't want to prompt again on the next occasion.
prompt.markShown(settings.val(write: true).notificationSettings);
if (granted) {
unawaited(PushRegistration().register());
return;
}
log('Push: notification permission declined on ${prompt.name}');
if (!context.mounted) return;
ConfirmDialog(
icon: Icons.notifications_off_outlined,
title: 'Benachrichtigungen deaktiviert',
content: prompt.declinedNote,
confirmButton: 'Einstellungen öffnen',
cancelButton: 'Später',
onConfirm: () =>
AppSettings.openAppSettings(type: AppSettingsType.notification),
).asDialog(context);
}
+11 -7
View File
@@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart'; import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_renderer.dart'; import 'push_renderer.dart';
@@ -38,7 +38,7 @@ void _plog(String message) {
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the /// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not /// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via /// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population. /// the [SessionManager] singleton after awaiting the stored session.
/// ///
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one /// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
/// on [handleBackgroundResponse]: the callback is resolved via /// on [handleBackgroundResponse]: the callback is resolved via
@@ -56,7 +56,7 @@ class PushActions {
) async { ) async {
// The FLN action isolate starts WITHOUT main(): unlike the FCM background // The FLN action isolate starts WITHOUT main(): unlike the FCM background
// isolate, plugins are not registered automatically there. Without this, // isolate, plugins are not registered automatically there. Without this,
// AccountData's secure-storage/prefs reads throw or never complete → no // The session's secure-storage/prefs reads throw or never complete → no
// auth header, the Talk POST never happens and the RemoteInput spinner // auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever. // runs forever.
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
@@ -125,7 +125,8 @@ class PushActions {
/// any) followed by the technical reason. /// any) followed by the technical reason.
static String actionFailureBody({String? lostText, required String detail}) { static String actionFailureBody({String? lostText, required String detail}) {
return [ return [
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText', if (lostText != null && lostText.isNotEmpty)
'Deine Nachricht: „$lostText',
'Grund: $detail', 'Grund: $detail',
].join('\n'); ].join('\n');
} }
@@ -188,7 +189,10 @@ class PushActions {
static Future<({bool ok, String detail})> sendReply( static Future<({bool ok, String detail})> sendReply(
String chatToken, String chatToken,
String message, String message,
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message}); ) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<({bool ok, String detail})> markRead(String chatToken) => static Future<({bool ok, String detail})> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
@@ -200,10 +204,10 @@ class PushActions {
try { try {
// Bounded: a hanging population (e.g. keystore issue) must fail the // Bounded: a hanging population (e.g. keystore issue) must fail the
// action instead of leaving the notification spinner running forever. // action instead of leaving the notification spinner running forever.
final populated = await AccountData().waitForPopulation().timeout( final session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
); );
if (!populated) { if (session?.nextcloud == null) {
_plog('Push action $path aborted: credentials unreadable in isolate'); _plog('Push action $path aborted: credentials unreadable in isolate');
return ( return (
ok: false, ok: false,
+15
View File
@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
/// Platform value MarianumConnect expects in push registrations.
String get pushPlatform => Platform.isIOS ? 'ios' : 'android';
/// App version sent along with push registrations; null when unavailable.
Future<String?> pushAppVersion() async {
try {
return (await PackageInfo.fromPlatform()).version;
} on Object {
return null;
}
}
+41 -29
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash; import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/demo/demo_mode.dart'; import '../api/demo/demo_mode.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart'; import '../api/marianumcloud/app_password/delete_app_password.dart';
@@ -11,9 +10,12 @@ import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart'; import '../model/endpoint_data.dart';
import '../session/nextcloud_credentials.dart';
import '../session/session_manager.dart';
import 'direct_push_registration.dart';
import 'nextcloud_push_api.dart'; import 'nextcloud_push_api.dart';
import 'push_device_info.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
import 'push_registration_store.dart'; import 'push_registration_store.dart';
import 'push_registration_type.dart'; import 'push_registration_type.dart';
@@ -48,8 +50,6 @@ class PushRegistration {
_store = store ?? const PushRegistrationStore(), _store = store ?? const PushRegistrationStore(),
_nextcloud = nextcloud ?? NextcloudPushApi(); _nextcloud = nextcloud ?? NextcloudPushApi();
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent => String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid; Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
@@ -63,20 +63,29 @@ class PushRegistration {
/// slash) — persisted alongside the registration to detect endpoint changes. /// slash) — persisted alongside the registration to detect endpoint changes.
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
NextcloudCredentials? get _nextcloudOrNull =>
SessionManager().current?.nextcloud;
/// Channel for sessions without Nextcloud; see [DirectPushRegistration].
static const _direct = DirectPushRegistration();
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering. /// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async { Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return; final nextcloud = _nextcloudOrNull;
if (AccountData().usesLoginFlow) { if (nextcloud == null || nextcloud.hasAppPassword) return;
if (nextcloud.usesLoginFlow) {
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt, // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
// stilles Minting ist unmöglich. Reparatur nur interaktiv über // stilles Minting ist unmöglich. Reparatur nur interaktiv über
// Einstellungen → „Nextcloud neu verbinden". // Einstellungen → „Nextcloud neu verbinden".
log('Push: login-flow account without app password, cannot mint silently'); log(
'Push: login-flow account without app password, cannot mint silently',
);
return; return;
} }
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword); await SessionManager().setAppPassword(appPassword);
} on Object catch (e) { } on Object catch (e) {
log('Push: could not obtain app password (non-blocking): $e'); log('Push: could not obtain app password (non-blocking): $e');
} }
@@ -85,15 +94,16 @@ class PushRegistration {
/// Ensures the second app password backing the Talk registration exists /// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one). /// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async { Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return; final nextcloud = _nextcloudOrNull;
if (nextcloud == null || nextcloud.hasAppPasswordTalk) return;
// Flow-Konten können still kein zweites App-Passwort münzen — das // Flow-Konten können still kein zweites App-Passwort münzen — das
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe // teilt sich die Talk-Registrierung das eine App-Passwort (siehe
// AccountData.getTalkBasicAuthHeader). // NextcloudCredentials.talkBasicAuthHeader).
if (AccountData().usesLoginFlow) return; if (nextcloud.usesLoginFlow) return;
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword); await SessionManager().setAppPasswordTalk(appPassword);
} on Object catch (e) { } on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e'); log('Push: could not obtain talk app password (non-blocking): $e');
} }
@@ -107,6 +117,7 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result). /// fire-and-forget (and simply ignore the result).
Future<bool> register() async { Future<bool> register() async {
if (DemoMode.active) return false; if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return _direct.register();
final String? fcmToken; final String? fcmToken;
try { try {
fcmToken = await FirebaseMessaging.instance.getToken(); fcmToken = await FirebaseMessaging.instance.getToken();
@@ -134,16 +145,13 @@ class PushRegistration {
return false; return false;
} }
String? appVersion; final appVersion = await pushAppVersion();
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
// Re-read: the ensure* calls above may have swapped the credentials.
final nextcloud = SessionManager().requireNextcloud();
final types = registrationTypesFor( final types = registrationTypesFor(
usesLoginFlow: AccountData().usesLoginFlow, usesLoginFlow: nextcloud.usesLoginFlow,
hasTalkAppPassword: AccountData().hasAppPasswordTalk(), hasTalkAppPassword: nextcloud.hasAppPasswordTalk,
); );
if (!types.contains(PushRegistrationType.general)) { if (!types.contains(PushRegistrationType.general)) {
await _recordAttempt( await _recordAttempt(
@@ -180,7 +188,7 @@ class PushRegistration {
devicePublicKeyPem: pems.publicKeyPem, devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer, proxyServer: proxyServer,
authorizationHeader: isTalk authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader() ? SessionManager().requireNextcloud().talkBasicAuthHeader
: null, : null,
userAgent: isTalk ? _talkUserAgent : null, userAgent: isTalk ? _talkUserAgent : null,
); );
@@ -199,7 +207,7 @@ class PushRegistration {
deviceIdentifierSignature: registration.signature, deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey, userPublicKey: registration.publicKey,
pushToken: fcmToken, pushToken: fcmToken,
platform: _platform, platform: pushPlatform,
registrationType: type.wireName, registrationType: type.wireName,
appVersion: appVersion, appVersion: appVersion,
); );
@@ -248,7 +256,7 @@ class PushRegistration {
try { try {
final endpoint = EndpointData().nextcloud(); final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext( await _store.saveNativeAuthContext(
username: AccountData().getUsername(), username: SessionManager().requireNextcloud().username,
baseUrl: 'https://${endpoint.full()}', baseUrl: 'https://${endpoint.full()}',
); );
} on Object catch (e) { } on Object catch (e) {
@@ -266,7 +274,7 @@ class PushRegistration {
// session token — each registration with its own app password. // session token — each registration with its own app password.
await _nextcloud.unregister( await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader() ? SessionManager().requireNextcloud().talkBasicAuthHeader
: null, : null,
); );
} on Object catch (e) { } on Object catch (e) {
@@ -405,7 +413,9 @@ class PushRegistration {
static Future<bool> syncSubscription({required bool capable}) async { static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false; if (!capable) return false;
if (!await isOsPermissionGranted()) { if (!await isOsPermissionGranted()) {
log('Push: OS notification permission not granted, skipping registration'); log(
'Push: OS notification permission not granted, skipping registration',
);
return false; return false;
} }
final registration = PushRegistration(); final registration = PushRegistration();
@@ -429,6 +439,7 @@ class PushRegistration {
/// pushing before credentials are gone. /// pushing before credentials are gone.
Future<void> logoutCleanup() async { Future<void> logoutCleanup() async {
if (DemoMode.active) return; if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister(); await unregister();
try { try {
await DeleteAppPassword().run(); await DeleteAppPassword().run();
@@ -436,15 +447,16 @@ class PushRegistration {
log('Push: delete app password failed: $e'); log('Push: delete app password failed: $e');
} }
try { try {
if (AccountData().hasAppPasswordTalk()) { final nextcloud = SessionManager().requireNextcloud();
if (nextcloud.hasAppPasswordTalk) {
await DeleteAppPassword().run( await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(), authorizationHeader: nextcloud.talkBasicAuthHeader,
); );
} }
} on Object catch (e) { } on Object catch (e) {
log('Push: delete talk app password failed: $e'); log('Push: delete talk app password failed: $e');
} }
await AccountData().clearAppPassword(); await SessionManager().clearAppPassword();
await AccountData().clearAppPasswordTalk(); await SessionManager().clearAppPasswordTalk();
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ class PushRegistrationStore {
// (reply / mark-as-read) directly via URLSession while the Flutter engine is // (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the // not guaranteed to run. It needs the Nextcloud username and base URL from the
// shared (group-scoped) keychain; the app password already lives there // shared (group-scoped) keychain; the app password already lives there
// (AccountData writes `nextcloud_app_password` group-scoped). // (SessionManager writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username'; static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url'; static const _baseUrlKey = 'nextcloud_base_url';
// Mirror of the in-app notification toggle (`notificationSettings.enabled`), // Mirror of the in-app notification toggle (`notificationSettings.enabled`),
+28 -5
View File
@@ -12,6 +12,7 @@ import 'nid_store.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_avatar.dart'; import 'push_avatar.dart';
import 'push_subject.dart'; import 'push_subject.dart';
import 'push_target.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local /// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE /// notifications. Talk messages of one chat stack into a SINGLE
@@ -23,6 +24,8 @@ class PushRenderer {
static const talkChannelName = 'Talk-Nachrichten'; static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general'; static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen'; static const generalChannelName = 'Benachrichtigungen';
static const parentLettersChannelId = 'parent_letters';
static const parentLettersChannelName = 'Elternbriefe';
static const String iosTalkCategory = 'TALK_MESSAGE'; static const String iosTalkCategory = 'TALK_MESSAGE';
@@ -67,6 +70,14 @@ class PushRenderer {
description: 'Allgemeine Benachrichtigungen', description: 'Allgemeine Benachrichtigungen',
), ),
); );
await android.createNotificationChannel(
const AndroidNotificationChannel(
parentLettersChannelId,
parentLettersChannelName,
description: 'Neue Elternbriefe und Antworten der Schule',
importance: Importance.high,
),
);
} }
/// Renders a decrypted Nextcloud push subject. /// Renders a decrypted Nextcloud push subject.
@@ -350,23 +361,35 @@ class PushRenderer {
required String body, required String body,
Map<String, String>? data, Map<String, String>? data,
}) async { }) async {
final id = _fallbackId('$title$body'); final parentLetterId = data?[parentLetterIdKey];
const androidDetails = AndroidNotificationDetails( final isParentLetter =
generalChannelId, data?['type'] == parentLetterPushType &&
generalChannelName, parentLetterId != null &&
parentLetterId.isNotEmpty;
// One notification per letter: a reply replaces the letter's earlier one.
final id = isParentLetter
? parentLetterNotificationId(parentLetterId)
: _fallbackId('$title$body');
final androidDetails = AndroidNotificationDetails(
isParentLetter ? parentLettersChannelId : generalChannelId,
isParentLetter ? parentLettersChannelName : generalChannelName,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
color: _accentColor, color: _accentColor,
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
); );
await _plugin.show( await _plugin.show(
id: id, id: id,
title: title, title: title,
body: body, body: body,
notificationDetails: const NotificationDetails(android: androidDetails), notificationDetails: NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data), payload: data == null ? null : jsonEncode(data),
); );
} }
static int parentLetterNotificationId(String letterId) =>
stableChatNotificationId('parent-letter:$letterId');
String _payload({required String? chatToken, required int nid}) => String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid}); jsonEncode({'chatToken': ?chatToken, 'nid': nid});
+1 -1
View File
@@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions(
); );
/// Shared secure storage instance for all push key material and registration /// Shared secure storage instance for all push key material and registration
/// bookkeeping. Kept separate from [AccountData]'s default storage because the /// bookkeeping. Kept separate from the session's default storage because the
/// entries here are group-scoped for NSE access. /// entries here are group-scoped for NSE access.
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage( const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions, iOptions: kPushIosOptions,
+4 -3
View File
@@ -1,7 +1,7 @@
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../model/account_data.dart'; import '../session/session_manager.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
import 'push_registration.dart'; import 'push_registration.dart';
import 'push_registration_store.dart'; import 'push_registration_store.dart';
@@ -125,14 +125,15 @@ Future<PushStatusReport> collectPushStatus({
lastRegistrationError: await store.lastRegistrationError(type), lastRegistrationError: await store.lastRegistrationError(type),
); );
final nextcloud = SessionManager().current?.nextcloud;
return PushStatusReport( return PushStatusReport(
settingEnabled: settingEnabled, settingEnabled: settingEnabled,
osPermission: await _osPermission(), osPermission: await _osPermission(),
serverCapability: !capabilitiesLoaded serverCapability: !capabilitiesLoaded
? PushCheck.unknown ? PushCheck.unknown
: (capabilityPush ? PushCheck.ok : PushCheck.fail), : (capabilityPush ? PushCheck.ok : PushCheck.fail),
appPasswordPresent: AccountData().hasAppPassword(), appPasswordPresent: nextcloud?.hasAppPassword ?? false,
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(), talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false,
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false, keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
general: await typeStatus(PushRegistrationType.general), general: await typeStatus(PushRegistrationType.general),
talk: await typeStatus(PushRegistrationType.talk), talk: await typeStatus(PushRegistrationType.talk),
+27 -19
View File
@@ -1,24 +1,22 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_target.dart';
/// Routes foreground notification interactions from the single /// Routes foreground notification interactions from the single
/// flutter_local_notifications response callback. Action responses (reply / /// flutter_local_notifications response callback. Action responses (reply /
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes /// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
/// the target chat token via [pendingChatToken] for [App] to navigate to. /// its target via [pendingTarget] for [App] to navigate to.
class PushTapRouter { class PushTapRouter {
PushTapRouter._(); PushTapRouter._();
/// Chat token of the most recently tapped Talk notification, or null. [App] /// Target of the most recently tapped notification, or null. [App] listens
/// listens to this and opens the chat, then resets it to null. /// to this and navigates, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null); static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
/// Newsletter id of the most recently tapped Marianum-Message notification,
/// or null. [App] listens to this and opens the message, then resets it.
static final ValueNotifier<String?> pendingNewsletterId = ValueNotifier(null);
static void handleResponse(NotificationResponse response) { static void handleResponse(NotificationResponse response) {
final actionId = response.actionId; final actionId = response.actionId;
@@ -29,13 +27,28 @@ class PushTapRouter {
} }
final map = _payloadMap(response.payload); final map = _payloadMap(response.payload);
if (map == null) return; if (map == null) return;
final newsletterId = _stringValue(map, 'newsletterId'); final target = resolvePushTarget(map);
if (newsletterId != null) { if (target != null) pendingTarget.value = target;
pendingNewsletterId.value = newsletterId; }
return;
static bool _launchHandled = false;
/// Routes the tap that cold-started the app. The plugin reports such a tap
/// only through its launch details, never through the response callback;
/// the details stay set for the whole process, hence the one-shot guard.
static Future<void> handleAppLaunch(
FlutterLocalNotificationsPlugin plugin,
) async {
if (_launchHandled) return;
_launchHandled = true;
try {
final details = await plugin.getNotificationAppLaunchDetails();
final response = details?.notificationResponse;
if (details?.didNotificationLaunchApp != true || response == null) return;
handleResponse(response);
} on Object catch (e) {
log('Reading the notification launch details failed: $e');
} }
final token = _stringValue(map, 'chatToken');
if (token != null) pendingChatToken.value = token;
} }
static Map<String, dynamic>? _payloadMap(String? payload) { static Map<String, dynamic>? _payloadMap(String? payload) {
@@ -46,9 +59,4 @@ class PushTapRouter {
return null; return null;
} }
} }
static String? _stringValue(Map<String, dynamic> map, String key) {
final value = map[key];
return value is String && value.isNotEmpty ? value : null;
}
} }
+45
View File
@@ -0,0 +1,45 @@
/// `type` of the visible Connect push for a parent letter. Its data carries
/// the letter under [parentLetterIdKey].
const String parentLetterPushType = 'parent-letter';
const String parentLetterIdKey = 'parentLetterId';
/// Where a tapped notification leads.
sealed class PushTarget {
const PushTarget();
}
class ParentLetterTarget extends PushTarget {
final String letterId;
const ParentLetterTarget(this.letterId);
}
class NewsletterTarget extends PushTarget {
final String newsletterId;
const NewsletterTarget(this.newsletterId);
}
class ChatTarget extends PushTarget {
final String chatToken;
const ChatTarget(this.chatToken);
}
/// Resolves the data of a tapped notification — the payload of a locally
/// rendered one as well as the data of an FCM message. Null when it names no
/// known target.
PushTarget? resolvePushTarget(Map<String, dynamic> data) {
String? value(String key) {
final value = data[key];
return value is String && value.isNotEmpty ? value : null;
}
if (value(parentLetterIdKey) case final letterId?) {
return ParentLetterTarget(letterId);
}
if (value('newsletterId') case final newsletterId?) {
return NewsletterTarget(newsletterId);
}
for (final key in const ['chatToken', 'token', 'roomToken']) {
if (value(key) case final chatToken?) return ChatTarget(chatToken);
}
return null;
}
+15 -4
View File
@@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart';
import '../notification/notification_tasks.dart'; import '../notification/notification_tasks.dart';
import '../session/session_manager.dart';
import '../share_intent/pending_share.dart'; import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
@@ -26,6 +26,7 @@ import '../view/pages/marianum_message/marianum_message_view.dart';
import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/feedback/feedback_dialog.dart';
import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart'; import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart'; import '../view/pages/settings/settings.dart';
@@ -203,6 +204,12 @@ class AppRoutes {
); );
} }
/// Opens a parent letter by id — from the inbox as well as from push deep
/// links, where only the id is known.
static void openParentLetter(BuildContext context, {required String id}) {
pushScreen(context, withNavBar: false, screen: ParentLetterView(id: id));
}
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail /// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
/// screen. Used for deep links from outside the ticker module — inside the /// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead. /// module pages open in-place instead.
@@ -374,7 +381,10 @@ class AppRoutes {
// ChatBloc._loadChat with the freshly-fetched maxId — sending one // ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id // here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor. // would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id); context.read<ChatListBloc>().markRoomAsRead(
room.token,
room.lastMessage.id,
);
NotificationTasks.clearNotificationsForChat(room.token); NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView( TalkNavigator.pushSplitView(
context, context,
@@ -404,7 +414,8 @@ class AppRoutes {
static ResolvedPendingChat? resolvePendingChat(BuildContext context) { static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
final token = pendingChatToken.value; final token = pendingChatToken.value;
if (token == null) return null; if (token == null) return null;
if (!AccountData().isPopulated()) return null; final nextcloud = SessionManager().current?.nextcloud;
if (nextcloud == null) return null;
final rooms = context.read<ChatListBloc>().state.data?.rooms; final rooms = context.read<ChatListBloc>().state.data?.rooms;
final room = _findRoomByToken(rooms, token); final room = _findRoomByToken(rooms, token);
@@ -417,7 +428,7 @@ class AppRoutes {
); );
return ResolvedPendingChat( return ResolvedPendingChat(
room: room, room: room,
selfId: AccountData().getUsername(), selfId: nextcloud.username,
avatar: avatar, avatar: avatar,
); );
} }
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
/// Nextcloud identity of a session. Immutable; the session manager swaps in a
/// new instance whenever an app password is minted or revoked.
class NextcloudCredentials {
final String username;
/// The real account password. Invalid against Nextcloud when
/// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works.
final String password;
final String? appPassword;
/// Backs the second (apptype=talk) push registration — Nextcloud binds each
/// push subscription to its session token, so two registrations need two
/// app passwords.
final String? appPasswordTalk;
/// True when the credentials came from Login Flow v2 (browser login, e.g.
/// because the account has two-factor authentication).
final bool usesLoginFlow;
const NextcloudCredentials({
required this.username,
required this.password,
this.appPassword,
this.appPasswordTalk,
this.usesLoginFlow = false,
});
bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty;
bool get hasAppPasswordTalk =>
appPasswordTalk != null && appPasswordTalk!.isNotEmpty;
/// The app password once available (minted or flow-issued), otherwise the
/// real password. It survives real-password rotation and is what the push
/// registration is bound to.
String get secret => hasAppPassword ? appPassword! : password;
/// HTTP Basic header value. Prefer headers over credentials in URLs — error
/// logs and crash reports often capture the URL but not headers.
String get basicAuthHeader => _basicAuth(secret);
Map<String, String> get authHeaders => {'Authorization': basicAuthHeader};
/// Authenticates the apptype=talk push registration (and its unregister).
/// Throws when the talk password has not been minted yet; callers treat that
/// as a failed talk registration and retry on the next start.
String get talkBasicAuthHeader {
if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!);
// Login-flow account whose second (talk) flow pass was skipped: no silent
// minting possible, the talk registration shares the flow credential.
if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!);
throw StateError('Talk app password not available yet');
}
/// Always the real password. Needed to mint the app password via
/// `core/getapppassword` — an app password cannot mint another.
String get realPasswordBasicAuthHeader => _basicAuth(password);
NextcloudCredentials copyWith({
String? Function()? appPassword,
String? Function()? appPasswordTalk,
bool? usesLoginFlow,
}) => NextcloudCredentials(
username: username,
password: password,
appPassword: appPassword != null ? appPassword() : this.appPassword,
appPasswordTalk: appPasswordTalk != null
? appPasswordTalk()
: this.appPasswordTalk,
usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow,
);
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$username:$secret'))}';
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'nextcloud_credentials.dart';
/// The signed-in account. Exactly one session is active at a time; features
/// check for the backend identities they need ([nextcloud]) instead of
/// assuming every account has all of them.
sealed class Session {
/// Local demo session: every backend is served from fixtures (see DemoMode).
final bool isDemo;
const Session({this.isDemo = false});
/// Nextcloud identity, or null for accounts without one (guardians).
NextcloudCredentials? get nextcloud;
}
/// Student, teacher or staff account: username + password, backed by
/// MarianumConnect and Nextcloud (with the same username and password).
final class CredentialSession extends Session {
@override
final NextcloudCredentials nextcloud;
CredentialSession({
required String username,
required String password,
String? appPassword,
String? appPasswordTalk,
bool usesLoginFlow = false,
super.isDemo,
}) : nextcloud = NextcloudCredentials(
username: username,
password: password,
appPassword: appPassword,
appPasswordTalk: appPasswordTalk,
usesLoginFlow: usesLoginFlow,
);
const CredentialSession._(this.nextcloud, {super.isDemo});
String get username => nextcloud.username;
String get password => nextcloud.password;
CredentialSession withNextcloud(NextcloudCredentials nextcloud) =>
CredentialSession._(nextcloud, isDemo: isDemo);
/// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom
/// events migration.
String get legacyUserSecret =>
sha512.convert(utf8.encode('$username:$password')).toString();
}
/// Parent/guardian account: passwordless e-mail login, MarianumConnect only.
final class GuardianSession extends Session {
final String email;
const GuardianSession({required this.email, super.isDemo});
@override
NextcloudCredentials? get nextcloud => null;
}
/// Thrown when a Nextcloud-only feature is reached with a session that has no
/// Nextcloud identity. Indicates a missing gate, not a user error.
class NextcloudUnavailableException implements Exception {
const NextcloudUnavailableException();
@override
String toString() =>
'NextcloudUnavailableException: session has no Nextcloud account';
}
+70
View File
@@ -0,0 +1,70 @@
import 'session.dart';
/// Keychain keys of the session. Names are frozen: installed versions and the
/// iOS AppDelegate/NSE read them, so renaming would log every user out.
abstract final class SessionKeys {
static const username = 'username';
static const password = 'password';
static const appPassword = 'nextcloud_app_password';
static const appPasswordTalk = 'nextcloud_app_password_talk';
static const loginFlow = 'nextcloud_login_flow';
static const demo = 'is_demo';
// Added with guardian accounts. Absent on installs from before — see
// [decodeSession].
static const kind = 'session_kind';
static const guardianEmail = 'guardian_email';
static const kindCredential = 'credential';
static const kindGuardian = 'guardian';
}
/// Rebuilds the session from raw keychain values. Installs from before
/// guardian accounts carry no [SessionKeys.kind]; a stored username and
/// password then mean a credential session, so existing users stay signed in.
Session? decodeSession(Map<String, String?> raw) {
final isDemo = raw[SessionKeys.demo] == 'true';
switch (raw[SessionKeys.kind]) {
case SessionKeys.kindGuardian:
final email = raw[SessionKeys.guardianEmail];
if (email == null || email.isEmpty) return null;
return GuardianSession(email: email, isDemo: isDemo);
case null:
case SessionKeys.kindCredential:
final username = raw[SessionKeys.username];
final password = raw[SessionKeys.password];
if (username == null || password == null) return null;
return CredentialSession(
username: username,
password: password,
appPassword: raw[SessionKeys.appPassword],
appPasswordTalk: raw[SessionKeys.appPasswordTalk],
usesLoginFlow: raw[SessionKeys.loginFlow] == 'true',
isDemo: isDemo,
);
default:
// Written by a newer app version; unknown here, treat as signed out.
return null;
}
}
/// Keychain values for [session], excluding the group-scoped app passwords
/// (written separately so the iOS NSE can read them). `null` = delete.
Map<String, String?> encodeSessionFields(Session session) => switch (session) {
CredentialSession() => {
SessionKeys.kind: SessionKeys.kindCredential,
SessionKeys.username: session.username,
SessionKeys.password: session.password,
SessionKeys.demo: session.isDemo ? 'true' : null,
SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null,
SessionKeys.guardianEmail: null,
},
GuardianSession() => {
SessionKeys.kind: SessionKeys.kindGuardian,
SessionKeys.guardianEmail: session.email,
SessionKeys.demo: session.isDemo ? 'true' : null,
SessionKeys.username: null,
SessionKeys.password: null,
SessionKeys.loginFlow: null,
},
};
+33
View File
@@ -0,0 +1,33 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../auth_link/guardian_link_listener.dart';
import '../push/push_registration.dart';
import 'session_manager.dart';
abstract final class SessionLifecycle {
/// Why the last sign-out happened, when the user did not ask for it. The
/// login screen shows it once and clears it — without it an expired session
/// just drops the user on the login screen with no explanation.
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
/// (while those credentials still exist), then revoke the MC bearer token,
/// finally wipe the local session. Each step is best-effort so an offline
/// sign-out still reaches a clean local state.
static Future<void> signOut({String? notice}) async {
signOutNotice.value = notice;
try {
await PushRegistration().logoutCleanup();
} on Object catch (e) {
log('Sign-out: push cleanup failed: $e');
}
await AuthLogout().run();
await SessionManager().signOut();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
import 'nextcloud_credentials.dart';
import 'session.dart';
import 'session_codec.dart';
/// Owns the active [Session] and its persistence. One instance per isolate;
/// the widget background isolate reads the same keychain.
class SessionManager {
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
SessionKeys.kind,
SessionKeys.username,
SessionKeys.password,
SessionKeys.guardianEmail,
SessionKeys.demo,
SessionKeys.loginFlow,
];
static final SessionManager _instance = SessionManager._();
factory SessionManager() => _instance;
SessionManager._() {
unawaited(_loadWithRetry());
}
Completer<void> _loaded = Completer();
Session? _current;
Session? get current => _current;
bool get isSignedIn => _current != null;
bool get isDemo => _current?.isDemo ?? false;
/// Whether the session has a Nextcloud identity (Talk, Files, NC push).
bool get hasNextcloud => _current?.nextcloud != null;
/// True once the stored session has been read (or given up on).
bool get isLoaded => _loaded.isCompleted;
/// Resolves once the stored session is known. After [signOut] it stays
/// pending until the next sign-in.
Future<Session?> waitForLoad() async {
await _loaded.future;
return _current;
}
/// Stops waiting for the stored session; the app then behaves as signed
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_loaded.isCompleted) _loaded.complete();
}
/// Bumped when the server rejects a token that cannot be renewed silently
/// (passwordless accounts). The app confirms via [SessionValidator] before
/// signing out, so a transient 401 does not cost the session.
final ValueNotifier<int> unauthorizedSignal = ValueNotifier(0);
void reportUnauthorized() => unauthorizedSignal.value++;
NextcloudCredentials requireNextcloud() =>
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
/// Replaces any stored session completely; no prior [signOut] needed.
Future<void> signIn(Session session) async {
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
_writeGroupSecret(
SessionKeys.appPassword,
session.nextcloud?.appPassword,
),
_writeGroupSecret(
SessionKeys.appPasswordTalk,
session.nextcloud?.appPasswordTalk,
),
]);
_current = session;
if (!_loaded.isCompleted) _loaded.complete();
}
Future<void> signOut() async {
_loaded = Completer();
_current = null;
await Future.wait([
for (final field in _sessionFields) _secureStorage.delete(key: field),
_writeGroupSecret(SessionKeys.appPassword, null),
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
]);
}
/// Persists a freshly minted Nextcloud app password; from then on every
/// Nextcloud call authenticates with it instead of the real password.
Future<void> setAppPassword(String appPassword) async {
_updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword));
await _writeGroupSecret(SessionKeys.appPassword, appPassword);
}
Future<void> clearAppPassword() async {
_updateNextcloud((nc) => nc.copyWith(appPassword: () => null));
await _writeGroupSecret(SessionKeys.appPassword, null);
}
/// Adopts an app password obtained via Login Flow v2 and switches the
/// account into flow mode. A previously stored Talk app password belonged
/// to the old session era and is dropped — the second (optional) flow pass
/// stores a fresh one via [setAppPasswordTalk].
Future<void> setLoginFlow(String appPassword) async {
await setAppPassword(appPassword);
await clearAppPasswordTalk();
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
}
Future<void> setAppPasswordTalk(String appPassword) async {
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword));
await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword);
}
Future<void> clearAppPasswordTalk() async {
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null));
await _writeGroupSecret(SessionKeys.appPasswordTalk, null);
}
void _updateNextcloud(
NextcloudCredentials Function(NextcloudCredentials) update,
) {
final session = _current;
if (session is CredentialSession) {
_current = session.withNextcloud(update(session.nextcloud));
}
}
Future<void> _writeSecret(String key, String? value) => value == null
? _secureStorage.delete(key: key)
: _secureStorage.write(key: key, value: value);
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too. That
// keystore may be unavailable (entitlement not provisioned); the in-memory
// copy still serves this session.
Future<void> _writeGroupSecret(String key, String? value) async {
try {
if (value == null) {
await pushSecureStorage.delete(key: key);
} else {
await pushSecureStorage.write(key: key, value: value);
}
} on Object {
// ignore — see above
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_loaded.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('Session load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
// On the startup critical path (and every background wake): read in
// parallel instead of one keychain round-trip after the other.
final values = await Future.wait(
_sessionFields.map((field) => _secureStorage.read(key: field)),
);
final raw = Map<String, String?>.fromIterables(_sessionFields, values);
try {
final (appPassword, appPasswordTalk) = await (
pushSecureStorage.read(key: SessionKeys.appPassword),
pushSecureStorage.read(key: SessionKeys.appPasswordTalk),
).wait;
raw[SessionKeys.appPassword] = appPassword;
raw[SessionKeys.appPasswordTalk] = appPasswordTalk;
} on Object {
// Group keystore unavailable: fall back to the real password.
}
_current = decodeSession(raw);
if (!_loaded.isCompleted) _loaded.complete();
}
// Move credentials from the old SharedPreferences plain-text storage into the
// platform's secure keystore. Run once per install and clear the legacy keys.
Future<void> _migrateFromLegacyStorage() async {
final prefs = await SharedPreferences.getInstance();
final legacyUsername = prefs.getString(SessionKeys.username);
final legacyPassword = prefs.getString(SessionKeys.password);
if (legacyUsername == null || legacyPassword == null) return;
final hasSecure =
(await _secureStorage.read(key: SessionKeys.username)) != null;
if (!hasSecure) {
await _secureStorage.write(
key: SessionKeys.username,
value: legacyUsername,
);
await _secureStorage.write(
key: SessionKeys.password,
value: legacyPassword,
);
}
await prefs.remove(SessionKeys.username);
await prefs.remove(SessionKeys.password);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
final legacyValues = await Future.wait(
_sessionFields.map((field) => _legacySecureStorage.read(key: field)),
);
for (final (i, field) in _sessionFields.indexed) {
final value = legacyValues[i];
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
}
@@ -2,7 +2,9 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../session/session_manager.dart';
import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart'; import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart'; import '../../repository/repository.dart';
@@ -114,6 +116,13 @@ abstract class LoadableHydratedBloc<
add(Reset<TState>()); add(Reset<TState>());
} }
/// Backend identities the data needs. Without them loading is a no-op, so
/// reading the bloc in a session that lacks them (e.g. guardians and the
/// Nextcloud blocs) is harmless.
Set<AccessRequirement> get requirements => const {};
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
TState? get innerState => state.data; TState? get innerState => state.data;
TRepository get repo => _repository; TRepository get repo => _repository;
@@ -137,6 +146,7 @@ abstract class LoadableHydratedBloc<
); );
void fetch() { void fetch() {
if (!requirementsMet) return;
log('Fetching data for ${TState.toString()}'); log('Fetching data for ${TState.toString()}');
gatherData() gatherData()
.catchError((Object e) { .catchError((Object e) {
+44 -32
View File
@@ -1,10 +1,12 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../access/access_requirement.dart';
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../../../storage/modules_settings.dart'; import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.dart'; import '../../../view/pages/absence_report/absence_report_view.dart';
import '../../../view/pages/files/files.dart'; import '../../../view/pages/files/files.dart';
@@ -13,14 +15,18 @@ import '../../../view/pages/holidays/holidays_view.dart';
import '../../../view/pages/marianum_dates/marianum_dates_view.dart'; import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart'; import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart'; import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/parent_letters/parent_letters_view.dart';
import '../../../view/pages/talk/chat_list.dart'; import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/ticker/ticker_view.dart'; import '../../../view/pages/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart'; import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart'; import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart'; import '../../../widget/centered_leading.dart';
import '../../../widget/module_badge_icon.dart';
import '../infrastructure/loadable_state/loadable_state.dart'; import '../infrastructure/loadable_state/loadable_state.dart';
import 'chat_list/bloc/chat_list_bloc.dart'; import 'chat_list/bloc/chat_list_bloc.dart';
import 'chat_list/bloc/chat_list_state.dart'; import 'chat_list/bloc/chat_list_state.dart';
import 'parent_letters/bloc/parent_letters_bloc.dart';
import 'parent_letters/bloc/parent_letters_state.dart';
import 'settings/bloc/settings_cubit.dart'; import 'settings/bloc/settings_cubit.dart';
class AppModule { class AppModule {
@@ -38,6 +44,17 @@ class AppModule {
required this.create, required this.create,
}); });
/// Backend identities each module needs. Modules without an entry work for
/// every session.
static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud},
Modules.parentLetters: {AccessRequirement.guardian},
};
static bool isAvailableFor(Modules module, Session? session) =>
(requirements[module] ?? const {}).areMetBy(session);
static Map<Modules, AppModule> modules( static Map<Modules, AppModule> modules(
BuildContext context, { BuildContext context, {
bool showFiltered = false, bool showFiltered = false,
@@ -51,6 +68,18 @@ class AppModule {
breakerArea: BreakerArea.timetable, breakerArea: BreakerArea.timetable,
create: Timetable.new, create: Timetable.new,
), ),
Modules.parentLetters: AppModule(
Modules.parentLetters,
name: 'Elternbriefe',
icon: () =>
BlocBuilder<ParentLettersBloc, LoadableState<ParentLettersState>>(
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.mail_outline,
count: state.data?.unreadCount ?? 0,
),
),
create: ParentLettersView.new,
),
Modules.ticker: AppModule( Modules.ticker: AppModule(
Modules.ticker, Modules.ticker,
name: 'Ticker', name: 'Ticker',
@@ -64,34 +93,15 @@ class AppModule {
Modules.talk, Modules.talk,
name: 'Talk', name: 'Talk',
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>( icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
builder: (context, state) { builder: (context, state) => ModuleBadgeIcon(
final rooms = state.data?.rooms; icon: Icons.chat,
if (rooms == null || rooms.data.isEmpty) { count:
return const Icon(Icons.chat); state.data?.rooms?.data.fold<int>(
} 0,
final messages = rooms.data (sum, room) => sum + room.unreadMessages,
.map((e) => e.unreadMessages) ) ??
.reduce((a, b) => a + b); 0,
return badges.Badge( ),
showBadge: messages > 0,
position: badges.BadgePosition.topEnd(top: -3, end: -3),
stackFit: StackFit.loose,
badgeStyle: badges.BadgeStyle(
padding: const EdgeInsets.all(3),
badgeColor: Theme.of(context).primaryColor,
elevation: 1,
),
badgeContent: Text(
'$messages',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
child: const Icon(Icons.chat),
);
},
), ),
breakerArea: BreakerArea.talk, breakerArea: BreakerArea.talk,
create: ChatList.new, create: ChatList.new,
@@ -146,6 +156,9 @@ class AppModule {
), ),
}; };
final session = SessionManager().current;
available.removeWhere((key, _) => !isAvailableFor(key, session));
if (!showFiltered) { if (!showFiltered) {
available.removeWhere( available.removeWhere(
(key, value) => (key, value) =>
@@ -177,9 +190,7 @@ class AppModule {
for (final missing in Modules.values) { for (final missing in Modules.values) {
if (!seen.add(missing)) continue; if (!seen.add(missing)) continue;
var insertAt = 0; var insertAt = 0;
for (final predecessor in Modules.values.takeWhile( for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
(m) => m != missing,
)) {
final pos = order.indexOf(predecessor); final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1; if (pos >= insertAt) insertAt = pos + 1;
} }
@@ -285,6 +296,7 @@ class AppModule {
enum Modules { enum Modules {
timetable, timetable,
parentLetters,
ticker, ticker,
talk, talk,
files, files,
@@ -5,6 +5,8 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
import '../../../../../session/session.dart';
import '../../../../../session/session_manager.dart';
import 'capabilities_state.dart'; import 'capabilities_state.dart';
/// Holds the current user's mobile capability flags. Hydrated so the last /// Holds the current user's mobile capability flags. Hydrated so the last
@@ -21,17 +23,18 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
int? get timetableFutureDays => state.timetableFutureDays; int? get timetableFutureDays => state.timetableFutureDays;
/// Teacher accounts get the class shown on timetable tiles instead of their
/// own name (see TimetableAppointmentFactory.showClassInsteadOfTeacher).
bool get isTeacher => state.userType == 'TEACHER';
/// Refreshes capabilities from the server. On any failure (endpoint not yet /// Refreshes capabilities from the server. On any failure (endpoint not yet
/// live, network error, 4xx) the previously hydrated flags are kept but the /// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a /// state is marked `loaded` and `loadFailed` — a failed fetch never silently
/// capability, and an offline launch keeps whatever was cached. /// grants a capability, an offline launch keeps whatever was cached, and the
/// UI can offer a retry instead of claiming a guardian has no children.
Future<void> load() async { Future<void> load() async {
if (DemoMode.active) { if (DemoMode.active) {
emit(DemoCapabilities.state()); emit(
SessionManager().current is GuardianSession
? DemoCapabilities.guardianState()
: DemoCapabilities.state(),
);
return; return;
} }
try { try {
@@ -43,16 +46,17 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
timetablePastDays: response.timetablePastDays, timetablePastDays: response.timetablePastDays,
timetableFutureDays: response.timetableFutureDays, timetableFutureDays: response.timetableFutureDays,
userType: response.userType, userType: response.userType,
children: response.children,
loaded: true, loaded: true,
), ),
); );
} catch (e) { } catch (e) {
log('Failed to load capabilities: $e'); log('Failed to load capabilities: $e');
emit(state.copyWith(loaded: true)); emit(state.copyWith(loaded: true, loadFailed: true));
} }
} }
Future<void> reset() async => emit(const CapabilitiesState()); void reset() => emit(const CapabilitiesState());
@override @override
CapabilitiesState fromJson(Map<String, dynamic> json) { CapabilitiesState fromJson(Map<String, dynamic> json) {
@@ -1,10 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../access/user_role.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
part 'capabilities_state.freezed.dart'; part 'capabilities_state.freezed.dart';
part 'capabilities_state.g.dart'; part 'capabilities_state.g.dart';
@freezed @freezed
abstract class CapabilitiesState with _$CapabilitiesState { abstract class CapabilitiesState with _$CapabilitiesState {
const CapabilitiesState._();
const factory CapabilitiesState({ const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables, @Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications, @Default(false) bool pushNotifications,
@@ -12,14 +17,24 @@ abstract class CapabilitiesState with _$CapabilitiesState {
// client-side clamp; the (server-narrowed) school year alone governs. // client-side clamp; the (server-narrowed) school year alone governs.
int? timetablePastDays, int? timetablePastDays,
int? timetableFutureDays, int? timetableFutureDays,
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. // Wire value of the user type; read it through [role].
String? userType, String? userType,
// Students a guardian may see; empty for all other accounts.
@Default(<GuardianChild>[]) List<GuardianChild> children,
// Whether a capability response (or a definitive failure) has been // Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
@Default(false) bool loaded, @Default(false) bool loaded,
// True while the last attempt failed. Not persisted: a stale failure from
// the previous run must not colour a fresh start. Together with [loaded]
// it separates "confirmed no children" from "could not ask".
@JsonKey(includeToJson: false, includeFromJson: false)
@Default(false)
bool loadFailed,
}) = _CapabilitiesState; }) = _CapabilitiesState;
factory CapabilitiesState.fromJson(Map<String, Object?> json) => factory CapabilitiesState.fromJson(Map<String, Object?> json) =>
_$CapabilitiesStateFromJson(json); _$CapabilitiesStateFromJson(json);
UserRole get role => UserRole.parse(userType);
} }
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint // ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'capabilities_state.dart'; part of 'capabilities_state.dart';
@@ -9,19 +9,14 @@ part of 'capabilities_state.dart';
// FreezedGenerator // FreezedGenerator
// ************************************************************************** // **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off // dart format off
T _$identity<T>(T value) => value; T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$CapabilitiesState { mixin _$CapabilitiesState {
bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List<GuardianChild> get children; bool get loaded;@JsonKey(includeToJson: false, includeFromJson: false) bool get loadFailed;
// client-side clamp; the (server-narrowed) school year alone governs.
int? get timetablePastDays; int? get timetableFutureDays;// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
String? get userType;// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
bool get loaded;
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -34,16 +29,21 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded)); final _this = this as CapabilitiesState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, _this.viewForeignTimetables) || other.viewForeignTimetables == _this.viewForeignTimetables)&&(identical(other.pushNotifications, _this.pushNotifications) || other.pushNotifications == _this.pushNotifications)&&(identical(other.timetablePastDays, _this.timetablePastDays) || other.timetablePastDays == _this.timetablePastDays)&&(identical(other.timetableFutureDays, _this.timetableFutureDays) || other.timetableFutureDays == _this.timetableFutureDays)&&(identical(other.userType, _this.userType) || other.userType == _this.userType)&&const DeepCollectionEquality().equals(other.children, _this.children)&&(identical(other.loaded, _this.loaded) || other.loaded == _this.loaded)&&(identical(other.loadFailed, _this.loadFailed) || other.loadFailed == _this.loadFailed));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); int get hashCode {
final _this = this as CapabilitiesState;
return Object.hash(runtimeType,_this.viewForeignTimetables,_this.pushNotifications,_this.timetablePastDays,_this.timetableFutureDays,_this.userType,const DeepCollectionEquality().hash(_this.children),_this.loaded,_this.loadFailed);
}
@override @override
String toString() { String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; final _this = this as CapabilitiesState;
return 'CapabilitiesState(viewForeignTimetables: ${_this.viewForeignTimetables}, pushNotifications: ${_this.pushNotifications}, timetablePastDays: ${_this.timetablePastDays}, timetableFutureDays: ${_this.timetableFutureDays}, userType: ${_this.userType}, children: ${_this.children}, loaded: ${_this.loaded}, loadFailed: ${_this.loadFailed})';
} }
@@ -54,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded,@JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed
}); });
@@ -71,14 +71,16 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,Object? loadFailed = null,}) {
return _then(_self.copyWith( return _then(CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as String?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable
as List<GuardianChild>,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool,loadFailed: null == loadFailed ? _self.loadFailed : loadFailed // ignore: cast_nullable_to_non_nullable
as bool, as bool,
)); ));
} }
@@ -164,10 +166,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded, @JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState() when $default != null: case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded,_that.loadFailed);case _:
return orElse(); return orElse();
} }
@@ -185,10 +187,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded, @JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState(): case _CapabilitiesState():
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded,_that.loadFailed);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -205,10 +207,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded, @JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState() when $default != null: case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded,_that.loadFailed);case _:
return null; return null;
} }
@@ -219,22 +221,24 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _CapabilitiesState implements CapabilitiesState { class _CapabilitiesState extends CapabilitiesState {
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false}); const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List<GuardianChild> children = const <GuardianChild>[], this.loaded = false, @JsonKey(includeToJson: false, includeFromJson: false) this.loadFailed = false}): _children = children,super._();
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json); factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
@override@JsonKey() final bool viewForeignTimetables; @override@JsonKey() final bool viewForeignTimetables;
@override@JsonKey() final bool pushNotifications; @override@JsonKey() final bool pushNotifications;
// Days into the past/future the timetable may be scrolled. Null = no
// client-side clamp; the (server-narrowed) school year alone governs.
@override final int? timetablePastDays; @override final int? timetablePastDays;
@override final int? timetableFutureDays; @override final int? timetableFutureDays;
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
@override final String? userType; @override final String? userType;
// Whether a capability response (or a definitive failure) has been final List<GuardianChild> _children;
// observed at least once this session. Lets the UI distinguish "still @override@JsonKey() List<GuardianChild> get children {
// unknown" from "confirmed not allowed". if (_children is EqualUnmodifiableListView) return _children;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_children);
}
@override@JsonKey() final bool loaded; @override@JsonKey() final bool loaded;
@override@JsonKey(includeToJson: false, includeFromJson: false) final bool loadFailed;
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -249,16 +253,18 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&const DeepCollectionEquality().equals(other.children, _children)&&(identical(other.loaded, loaded) || other.loaded == loaded)&&(identical(other.loadFailed, loadFailed) || other.loadFailed == loadFailed));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); int get hashCode {
return Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,const DeepCollectionEquality().hash(_children),loaded,loadFailed);
}
@override @override
String toString() { String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, children: $children, loaded: $loaded, loadFailed: $loadFailed)';
} }
@@ -269,7 +275,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded,@JsonKey(includeToJson: false, includeFromJson: false) bool loadFailed
}); });
@@ -286,14 +292,16 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,Object? loadFailed = null,}) {
return _then(_CapabilitiesState( return _then(_CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as String?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable
as List<GuardianChild>,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool,loadFailed: null == loadFailed ? _self.loadFailed : loadFailed // ignore: cast_nullable_to_non_nullable
as bool, as bool,
)); ));
} }
@@ -13,6 +13,11 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?, userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
const <GuardianChild>[],
loaded: json['loaded'] as bool? ?? false, loaded: json['loaded'] as bool? ?? false,
); );
@@ -23,5 +28,6 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
'timetablePastDays': instance.timetablePastDays, 'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays, 'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType, 'userType': instance.userType,
'children': instance.children,
'loaded': instance.loaded, 'loaded': instance.loaded,
}; };
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -43,6 +44,11 @@ class ChatListBloc
}); });
} }
@override
Set<AccessRequirement> get requirements => const {
AccessRequirement.nextcloud,
};
@override @override
ChatListRepository repository() => ChatListRepository(); ChatListRepository repository() => ChatListRepository();
@@ -73,6 +79,7 @@ class ChatListBloc
} }
Future<void> refresh({bool renew = true, bool silent = false}) async { Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!requirementsMet) return;
if (!silent) add(RefetchStarted<ChatListState>()); if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError; Object? capturedError;
try { try {
@@ -0,0 +1,33 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
/// The child a guardian is currently looking at. Shared by every module that
/// shows per-child data (timetable, absence report, later messages), so
/// switching the child in one place switches it everywhere.
class ChildSelectionCubit extends HydratedCubit<String?> {
ChildSelectionCubit() : super(null);
void select(String childId) => emit(childId);
void reset() => emit(null);
@override
String? fromJson(Map<String, dynamic> json) => json['childId'] as String?;
@override
Map<String, dynamic>? toJson(String? state) => {'childId': state};
}
/// The selected child if it is still linked, otherwise the first one. Null
/// when there are no children.
GuardianChild? effectiveChild(
List<GuardianChild> children,
String? selectedId,
) {
if (children.isEmpty) return null;
for (final child in children) {
if (child.id == selectedId) return child;
}
return children.first;
}
@@ -1,204 +0,0 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/loadable_state/loadable_state.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../timetable/bloc/timetable_event.dart';
import '../../timetable/bloc/timetable_state.dart';
import '../repository/foreign_timetable_repository.dart';
/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading
/// and navigation but loads weeks from the element endpoint, carries no custom
/// events, and does not persist (page-scoped, recreated per element). Reuses
/// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents`
/// stays null (the foreign view's `isReady` predicate ignores it).
class ForeignTimetableBloc
extends
LoadableHydratedBloc<
TimetableEvent,
TimetableState,
ForeignTimetableRepository
> {
final TimetableElementType type;
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
// `String get id` (the storage key), which a plain `int id` would illegally
// override.
final int elementId;
final String title;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
ForeignTimetableBloc({
required this.type,
required this.elementId,
required this.title,
});
@override
ForeignTimetableRepository repository() => ForeignTimetableRepository();
@override
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
endDate: _endOfWeek(reference),
);
}
// Persistence disabled: page-scoped and element-specific, nothing worth
// restoring. toJson returns null so nothing is written; fromJson starts fresh.
@override
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) => null;
@override
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
const LoadableState(
isLoading: true,
data: null,
lastFetch: null,
reFetch: null,
error: null,
);
@override
TimetableState fromStorage(Map<String, dynamic> json) => fromNothing();
@override
Map<String, dynamic>? toStorage(TimetableState state) => null;
@override
Future<void> gatherData() async {
final initial = innerState ?? fromNothing();
Object? firstError;
void recordError(Object e) {
firstError ??= e;
}
await Future.wait([
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError),
_loadStaticReferenceData(onError: recordError),
]);
if (firstError != null) throw firstError!;
add(DataGathered((s) => s));
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
}
void changeWeek(DateTime startDate, DateTime endDate) {
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getElementWeek(
type,
elementId,
startDate,
endDate,
onError: onError,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('getElementWeek error for $startDate$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadStaticReferenceData({
void Function(Object)? onError,
}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
repo.data.getRooms(onError: onError),
repo.data.getSubjects(onError: onError),
repo.data.getSchoolHolidays(onError: onError),
repo.data.getCurrentSchoolyear(onError: onError),
).wait;
add(
Emit(
(s) => s.copyWith(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid();
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI.
}
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
_prefetchWeek(start.addDays(7), end.addDays(7));
}
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getElementWeek(type, elementId, start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = weekStart.weekKey();
add(
Emit((s) {
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
);
}
static DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtractDays(reference.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
}
@@ -1,64 +0,0 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../timetable/data_provider/timetable_data_provider.dart';
/// Data access for a foreign element's timetable. The week comes from the
/// element-specific endpoint; all reference data (rooms/subjects/holidays/
/// school year/timegrid) is school-wide, so it delegates to the existing
/// [TimetableDataProvider] (which caches it). Custom events are intentionally
/// absent — they are user-private.
class ForeignTimetableDataProvider {
final TimetableDataProvider _base;
ForeignTimetableDataProvider([TimetableDataProvider? base])
: _base = base ?? TimetableDataProvider();
Future<TimetableGetWeekResponse> getElementWeek(
TimetableElementType type,
int id,
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
try {
return await TimetableGetElementWeek().run(
type: type,
id: id,
from: startDate,
until: endDate,
);
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) => _base.getRooms(onError: onError, renew: renew);
Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSubjects(onError: onError, renew: renew);
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSchoolHolidays(onError: onError, renew: renew);
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError,
bool renew = false,
}) => _base.getCurrentSchoolyear(onError: onError, renew: renew);
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
_base.getTimegrid(renew: renew);
}
@@ -1,12 +0,0 @@
import '../../../infrastructure/repository/repository.dart';
import '../../timetable/bloc/timetable_state.dart';
import '../data_provider/foreign_timetable_data_provider.dart';
class ForeignTimetableRepository extends Repository<TimetableState> {
final ForeignTimetableDataProvider _provider;
ForeignTimetableRepository([ForeignTimetableDataProvider? provider])
: _provider = provider ?? ForeignTimetableDataProvider();
ForeignTimetableDataProvider get data => _provider;
}
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart'; import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
import '../../../../../session/session_manager.dart';
import 'nextcloud_capabilities_state.dart'; import 'nextcloud_capabilities_state.dart';
/// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so
@@ -59,6 +60,7 @@ class NextcloudCapabilitiesCubit
/// Refreshes capabilities from the server. On any failure the previously /// Refreshes capabilities from the server. On any failure the previously
/// hydrated flags are kept but the state is marked `loaded`. /// hydrated flags are kept but the state is marked `loaded`.
Future<void> load() async { Future<void> load() async {
if (!SessionManager().hasNextcloud) return;
if (DemoMode.active) { if (DemoMode.active) {
emit(DemoNextcloudCapabilities.state()); emit(DemoNextcloudCapabilities.state());
return; return;
@@ -90,7 +92,7 @@ class NextcloudCapabilitiesCubit
} }
} }
Future<void> reset() async => emit(const NextcloudCapabilitiesState()); void reset() => emit(const NextcloudCapabilitiesState());
@override @override
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) { NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
@@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:typed_data';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_exception.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../../../utils/random_id.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/parent_letter_repository.dart';
import 'parent_letter_event.dart';
import 'parent_letter_state.dart';
import 'parent_letters_bloc.dart';
/// One letter; [id] is the letter id so each letter keeps its own hydrated
/// cache entry. Changes are mirrored into the app-wide [inbox].
class ParentLetterBloc
extends
LoadableHydratedBloc<
ParentLetterEvent,
ParentLetterState,
ParentLetterRepository
> {
final String letterId;
final ParentLettersBloc inbox;
ParentLetterBloc(this.letterId, {required this.inbox});
@override
String get id => letterId;
@override
Future<void> gatherData() async {
final ParentLetterDetail letter;
try {
letter = await repo.getLetter(letterId);
} on ParentLetterException catch (e) {
if (e.error == ParentLetterError.letterNotFound) {
// Withdrawn: a hydrated copy must not stay readable.
add(Emit((state) => state.copyWith(letter: null)));
unawaited(inbox.refresh(silent: true));
}
rethrow;
}
add(DataGathered((state) => state.copyWith(letter: letter)));
if (!letter.summary.read) inbox.markRead(letterId);
}
Future<void> submitResponse({
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) async {
final letter = await repo.submitResponse(
letterId: letterId,
childId: childId,
answers: answers,
signaturePng: signaturePng,
);
if (isClosed) return;
add(Emit((state) => state.copyWith(letter: letter)));
inbox.applyDetail(letter);
}
Future<void> sendThreadMessage(String body) async {
final message = await repo.sendThreadMessage(
letterId: letterId,
body: body,
clientMessageId: randomUuidV4(),
);
if (isClosed) return;
add(
Emit((state) {
final letter = state.letter;
if (letter == null) return state;
final thread = letter.content.thread;
return state.copyWith(
letter: ParentLetterDetail(
summary: letter.summary,
content: letter.content.copyWith(
thread: thread.copyWith(messages: [...thread.messages, message]),
),
),
);
}),
);
}
Future<Uint8List> loadAttachment(String attachmentId) =>
repo.getAttachment(letterId: letterId, attachmentId: attachmentId);
@override
ParentLetterRepository repository() => ParentLetterRepository();
@override
ParentLetterState fromNothing() => const ParentLetterState();
@override
ParentLetterState fromStorage(Map<String, dynamic> json) =>
ParentLetterState.fromJson(json);
@override
Map<String, dynamic>? toStorage(ParentLetterState state) => state.toJson();
}
@@ -0,0 +1,5 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'parent_letter_state.dart';
sealed class ParentLetterEvent
extends LoadableHydratedBlocEvent<ParentLetterState> {}
@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
part 'parent_letter_state.freezed.dart';
part 'parent_letter_state.g.dart';
@freezed
abstract class ParentLetterState with _$ParentLetterState {
const factory ParentLetterState({ParentLetterDetail? letter}) =
_ParentLetterState;
factory ParentLetterState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterStateFromJson(json);
}
@@ -0,0 +1,285 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'parent_letter_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ParentLetterState {
ParentLetterDetail? get letter;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ParentLetterStateCopyWith<ParentLetterState> get copyWith => _$ParentLetterStateCopyWithImpl<ParentLetterState>(this as ParentLetterState, _$identity);
/// Serializes this ParentLetterState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as ParentLetterState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ParentLetterState&&(identical(other.letter, _this.letter) || other.letter == _this.letter));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ParentLetterState;
return Object.hash(runtimeType,_this.letter);
}
@override
String toString() {
final _this = this as ParentLetterState;
return 'ParentLetterState(letter: ${_this.letter})';
}
}
/// @nodoc
abstract mixin class $ParentLetterStateCopyWith<$Res> {
factory $ParentLetterStateCopyWith(ParentLetterState value, $Res Function(ParentLetterState) _then) = _$ParentLetterStateCopyWithImpl;
@useResult
$Res call({
ParentLetterDetail? letter
});
}
/// @nodoc
class _$ParentLetterStateCopyWithImpl<$Res>
implements $ParentLetterStateCopyWith<$Res> {
_$ParentLetterStateCopyWithImpl(this._self, this._then);
final ParentLetterState _self;
final $Res Function(ParentLetterState) _then;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? letter = freezed,}) {
return _then(ParentLetterState(
letter: freezed == letter ? _self.letter : letter // ignore: cast_nullable_to_non_nullable
as ParentLetterDetail?,
));
}
}
/// Adds pattern-matching-related methods to [ParentLetterState].
extension ParentLetterStatePatterns on ParentLetterState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ParentLetterState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ParentLetterState value) $default,){
final _that = this;
switch (_that) {
case _ParentLetterState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ParentLetterState value)? $default,){
final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( ParentLetterDetail? letter)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that.letter);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( ParentLetterDetail? letter) $default,) {final _that = this;
switch (_that) {
case _ParentLetterState():
return $default(_that.letter);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( ParentLetterDetail? letter)? $default,) {final _that = this;
switch (_that) {
case _ParentLetterState() when $default != null:
return $default(_that.letter);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ParentLetterState implements ParentLetterState {
const _ParentLetterState({this.letter});
factory _ParentLetterState.fromJson(Map<String, dynamic> json) => _$ParentLetterStateFromJson(json);
@override final ParentLetterDetail? letter;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ParentLetterStateCopyWith<_ParentLetterState> get copyWith => __$ParentLetterStateCopyWithImpl<_ParentLetterState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ParentLetterStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ParentLetterState&&(identical(other.letter, letter) || other.letter == letter));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,letter);
}
@override
String toString() {
return 'ParentLetterState(letter: $letter)';
}
}
/// @nodoc
abstract mixin class _$ParentLetterStateCopyWith<$Res> implements $ParentLetterStateCopyWith<$Res> {
factory _$ParentLetterStateCopyWith(_ParentLetterState value, $Res Function(_ParentLetterState) _then) = __$ParentLetterStateCopyWithImpl;
@override @useResult
$Res call({
ParentLetterDetail? letter
});
}
/// @nodoc
class __$ParentLetterStateCopyWithImpl<$Res>
implements _$ParentLetterStateCopyWith<$Res> {
__$ParentLetterStateCopyWithImpl(this._self, this._then);
final _ParentLetterState _self;
final $Res Function(_ParentLetterState) _then;
/// Create a copy of ParentLetterState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? letter = freezed,}) {
return _then(_ParentLetterState(
letter: freezed == letter ? _self.letter : letter // ignore: cast_nullable_to_non_nullable
as ParentLetterDetail?,
));
}
}
// dart format on
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterState _$ParentLetterStateFromJson(Map<String, dynamic> json) =>
_ParentLetterState(
letter: json['letter'] == null
? null
: ParentLetterDetail.fromJson(json['letter'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterStateToJson(_ParentLetterState instance) =>
<String, dynamic>{'letter': instance.letter};
@@ -0,0 +1,107 @@
import 'dart:developer';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../parent_letters_logic.dart';
import '../repository/parent_letters_repository.dart';
import 'parent_letters_event.dart';
import 'parent_letters_state.dart';
/// The guardian's letter inbox. Lives app-wide because the module badge and
/// push handling need it outside the page.
class ParentLettersBloc
extends
LoadableHydratedBloc<
ParentLettersEvent,
ParentLettersState,
ParentLettersRepository
> {
@override
Set<AccessRequirement> get requirements => const {AccessRequirement.guardian};
Future<void>? _loading;
/// App start, prefetch, page visit and push handling all ask for the first
/// page within moments of each other; they share one request.
@override
Future<void> gatherData() =>
_loading ??= _loadFirstPage().whenComplete(() => _loading = null);
Future<void> _loadFirstPage() async {
final page = await repo.getLetters();
if (isClosed) return;
add(DataGathered((_) => firstPageState(page)));
}
/// Reloads the first page; older pages loaded via [loadOlder] are dropped.
Future<void> refresh({bool silent = false}) async {
if (!requirementsMet) return;
if (!silent) add(RefetchStarted<ParentLettersState>());
try {
await gatherData();
} catch (e) {
if (isClosed) return;
if (silent) {
log('Silent parent letters refresh failed: $e');
} else {
addLoadingError(e);
}
}
}
Future<void> loadOlder() async {
final letters = innerState?.letters;
if (letters == null || letters.isEmpty) return;
final page = await repo.getLetters(before: letters.last.id);
if (isClosed) return;
add(Emit((state) => appendOlderPage(state, page)));
}
/// Optimistic locally. When nothing could be applied locally (a letter
/// opened from a push is not in the inbox yet), the follow-up refresh picks
/// it up.
void markRead(String letterId) {
final current = innerState;
final updated = current == null ? null : withLetterRead(current, letterId);
final appliedLocally = updated != null && !identical(updated, current);
if (appliedLocally) add(Emit((_) => updated));
repo
.markRead(letterId)
.then((_) {
if (!appliedLocally) _reloadAfterWrite();
})
.catchError((Object e) {
log('Marking parent letter $letterId as read failed: $e');
_reloadAfterWrite();
});
}
/// A request already in flight may predate the write, so it is not joined.
Future<void> _reloadAfterWrite() async {
try {
await _loading;
} on Object {
// Its own caller reports the failure.
}
await refresh(silent: true);
}
/// Takes over a freshly loaded letter.
void applyDetail(ParentLetterDetail detail) =>
add(Emit((state) => withSummary(state, detail.summary)));
@override
ParentLettersRepository repository() => ParentLettersRepository();
@override
ParentLettersState fromNothing() => const ParentLettersState();
@override
ParentLettersState fromStorage(Map<String, dynamic> json) =>
ParentLettersState.fromJson(json);
@override
Map<String, dynamic>? toStorage(ParentLettersState state) => state.toJson();
}
@@ -0,0 +1,5 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'parent_letters_state.dart';
sealed class ParentLettersEvent
extends LoadableHydratedBlocEvent<ParentLettersState> {}
@@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
part 'parent_letters_state.freezed.dart';
part 'parent_letters_state.g.dart';
/// Hydrated inbox. [unreadCount] and [openCount] cover all letters on the
/// server, not just the loaded [letters].
@freezed
abstract class ParentLettersState with _$ParentLettersState {
const factory ParentLettersState({
@Default([]) List<ParentLetterSummary> letters,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLettersState;
factory ParentLettersState.fromJson(Map<String, dynamic> json) =>
_$ParentLettersStateFromJson(json);
}
@@ -0,0 +1,300 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'parent_letters_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ParentLettersState {
List<ParentLetterSummary> get letters; bool get hasMore; int get unreadCount; int get openCount;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ParentLettersStateCopyWith<ParentLettersState> get copyWith => _$ParentLettersStateCopyWithImpl<ParentLettersState>(this as ParentLettersState, _$identity);
/// Serializes this ParentLettersState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as ParentLettersState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is ParentLettersState&&const DeepCollectionEquality().equals(other.letters, _this.letters)&&(identical(other.hasMore, _this.hasMore) || other.hasMore == _this.hasMore)&&(identical(other.unreadCount, _this.unreadCount) || other.unreadCount == _this.unreadCount)&&(identical(other.openCount, _this.openCount) || other.openCount == _this.openCount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as ParentLettersState;
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.letters),_this.hasMore,_this.unreadCount,_this.openCount);
}
@override
String toString() {
final _this = this as ParentLettersState;
return 'ParentLettersState(letters: ${_this.letters}, hasMore: ${_this.hasMore}, unreadCount: ${_this.unreadCount}, openCount: ${_this.openCount})';
}
}
/// @nodoc
abstract mixin class $ParentLettersStateCopyWith<$Res> {
factory $ParentLettersStateCopyWith(ParentLettersState value, $Res Function(ParentLettersState) _then) = _$ParentLettersStateCopyWithImpl;
@useResult
$Res call({
List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount
});
}
/// @nodoc
class _$ParentLettersStateCopyWithImpl<$Res>
implements $ParentLettersStateCopyWith<$Res> {
_$ParentLettersStateCopyWithImpl(this._self, this._then);
final ParentLettersState _self;
final $Res Function(ParentLettersState) _then;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? letters = null,Object? hasMore = null,Object? unreadCount = null,Object? openCount = null,}) {
return _then(ParentLettersState(
letters: null == letters ? _self.letters : letters // ignore: cast_nullable_to_non_nullable
as List<ParentLetterSummary>,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool,unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
as int,openCount: null == openCount ? _self.openCount : openCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [ParentLettersState].
extension ParentLettersStatePatterns on ParentLettersState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ParentLettersState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ParentLettersState value) $default,){
final _that = this;
switch (_that) {
case _ParentLettersState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ParentLettersState value)? $default,){
final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount) $default,) {final _that = this;
switch (_that) {
case _ParentLettersState():
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount)? $default,) {final _that = this;
switch (_that) {
case _ParentLettersState() when $default != null:
return $default(_that.letters,_that.hasMore,_that.unreadCount,_that.openCount);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ParentLettersState implements ParentLettersState {
const _ParentLettersState({ List<ParentLetterSummary> letters = const [], this.hasMore = false, this.unreadCount = 0, this.openCount = 0}): _letters = letters;
factory _ParentLettersState.fromJson(Map<String, dynamic> json) => _$ParentLettersStateFromJson(json);
final List<ParentLetterSummary> _letters;
@override@JsonKey() List<ParentLetterSummary> get letters {
if (_letters is EqualUnmodifiableListView) return _letters;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_letters);
}
@override@JsonKey() final bool hasMore;
@override@JsonKey() final int unreadCount;
@override@JsonKey() final int openCount;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ParentLettersStateCopyWith<_ParentLettersState> get copyWith => __$ParentLettersStateCopyWithImpl<_ParentLettersState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ParentLettersStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ParentLettersState&&const DeepCollectionEquality().equals(other.letters, _letters)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.unreadCount, unreadCount) || other.unreadCount == unreadCount)&&(identical(other.openCount, openCount) || other.openCount == openCount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_letters),hasMore,unreadCount,openCount);
}
@override
String toString() {
return 'ParentLettersState(letters: $letters, hasMore: $hasMore, unreadCount: $unreadCount, openCount: $openCount)';
}
}
/// @nodoc
abstract mixin class _$ParentLettersStateCopyWith<$Res> implements $ParentLettersStateCopyWith<$Res> {
factory _$ParentLettersStateCopyWith(_ParentLettersState value, $Res Function(_ParentLettersState) _then) = __$ParentLettersStateCopyWithImpl;
@override @useResult
$Res call({
List<ParentLetterSummary> letters, bool hasMore, int unreadCount, int openCount
});
}
/// @nodoc
class __$ParentLettersStateCopyWithImpl<$Res>
implements _$ParentLettersStateCopyWith<$Res> {
__$ParentLettersStateCopyWithImpl(this._self, this._then);
final _ParentLettersState _self;
final $Res Function(_ParentLettersState) _then;
/// Create a copy of ParentLettersState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? letters = null,Object? hasMore = null,Object? unreadCount = null,Object? openCount = null,}) {
return _then(_ParentLettersState(
letters: null == letters ? _self._letters : letters // ignore: cast_nullable_to_non_nullable
as List<ParentLetterSummary>,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool,unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
as int,openCount: null == openCount ? _self.openCount : openCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letters_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLettersState _$ParentLettersStateFromJson(Map<String, dynamic> json) =>
_ParentLettersState(
letters:
(json['letters'] as List<dynamic>?)
?.map(
(e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLettersStateToJson(_ParentLettersState instance) =>
<String, dynamic>{
'letters': instance.letters,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
@@ -0,0 +1,67 @@
import '../../../../api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import 'bloc/parent_letters_state.dart';
ParentLettersState firstPageState(ParentLetterListResponse page) =>
ParentLettersState(
letters: page.items,
hasMore: page.hasMore,
unreadCount: page.unreadCount,
openCount: page.openCount,
);
ParentLettersState appendOlderPage(
ParentLettersState state,
ParentLetterListResponse page,
) {
final known = {for (final letter in state.letters) letter.id};
return state.copyWith(
letters: [
...state.letters,
...page.items.where((letter) => !known.contains(letter.id)),
],
hasMore: page.hasMore,
unreadCount: page.unreadCount,
openCount: page.openCount,
);
}
/// Marks [letterId] as read and lowers the unread counter. Returns [state]
/// itself when the letter is unknown or already read.
ParentLettersState withLetterRead(ParentLettersState state, String letterId) {
final index = state.letters.indexWhere((letter) => letter.id == letterId);
if (index < 0 || state.letters[index].read) return state;
final letters = [...state.letters];
letters[index] = letters[index].copyWith(read: true);
return state.copyWith(
letters: letters,
unreadCount: state.unreadCount > 0 ? state.unreadCount - 1 : 0,
);
}
/// Replaces the letter and moves [ParentLettersState.openCount] along with
/// its status, so answering does not need a reload to fix the counter.
ParentLettersState withSummary(
ParentLettersState state,
ParentLetterSummary summary,
) {
final index = state.letters.indexWhere((letter) => letter.id == summary.id);
if (index < 0) return state;
int open(ParentLetterSummary letter) =>
letter.status == ParentLetterStatus.open ? 1 : 0;
final openCount =
state.openCount - open(state.letters[index]) + open(summary);
final letters = [...state.letters];
letters[index] = summary;
return state.copyWith(
letters: letters,
openCount: openCount < 0 ? 0 : openCount,
);
}
/// [childId] null means all children.
List<ParentLetterSummary> filterLettersByChild(
List<ParentLetterSummary> letters,
String? childId,
) => childId == null
? letters
: letters.where((letter) => letter.childIds.contains(childId)).toList();

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